From 4fe5701b3bd7927f52eba10fbcfb536f99ac2627 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:31:17 +0200 Subject: [PATCH] feat: default sidebar apps for all users #931 --- app/(main)/admin/_tabs/policy.tsx | 233 +++++++++++++++++- app/api/admin/policy/route.ts | 9 +- components/layout/navigation-rail.tsx | 6 +- components/layout/sidebar-apps-modal.tsx | 51 +++- components/settings/settings-app.tsx | 4 +- components/settings/sidebar-apps-settings.tsx | 51 +++- hooks/use-resolved-sidebar-apps.ts | 29 +++ hooks/use-sidebar-apps.ts | 13 +- lib/__tests__/sidebar-apps.test.ts | 155 ++++++++++++ lib/admin/config-manager.ts | 4 + lib/admin/types.ts | 25 ++ lib/sidebar-apps.ts | 112 +++++++++ locales/ar/common.json | 5 + locales/ca/common.json | 5 + locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/fa/common.json | 5 + locales/fr/common.json | 5 + locales/he/common.json | 5 + locales/hu/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/mn/common.json | 5 + locales/nb/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ro/common.json | 5 + locales/ru/common.json | 5 + locales/sk/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh/common.json | 5 + proxy.ts | 13 +- 39 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 hooks/use-resolved-sidebar-apps.ts create mode 100644 lib/__tests__/sidebar-apps.test.ts create mode 100644 lib/sidebar-apps.ts diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index d42454631..d2b560bae 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -1,10 +1,13 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Save, Loader2, Lock, Plus, Trash2 } from 'lucide-react'; -import type { SettingsPolicy, FeatureGates, PushRelayOption } from '@/lib/admin/types'; +import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from 'lucide-react'; +import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; +import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; +import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps'; +import { generateUUID } from '@/lib/utils'; import { DEFAULT_RELAY_BASE_URL, isValidRelayUrl, @@ -56,6 +59,16 @@ const RESTRICTABLE_SETTINGS = [ { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, ]; +/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */ +function isValidDefaultAppUrl(raw: string): boolean { + try { + const parsed = new URL(raw.trim()); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host; + } catch { + return false; + } +} + export function PolicyTab() { const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [loading, setLoading] = useState(true); @@ -127,6 +140,60 @@ export function PolicyTab() { setMessage(null); } + function updateDefaultApp(index: number, patch: Partial) { + setPolicy(prev => ({ + ...prev, + defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)), + })); + setDirty(true); + setMessage(null); + } + + function addDefaultApp() { + setPolicy(prev => { + const apps = prev.defaultSidebarApps ?? []; + if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev; + return { + ...prev, + defaultSidebarApps: [ + ...apps, + { + id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`, + name: '', + url: '', + icon: 'Globe', + openMode: 'tab' as const, + showOnMobile: false, + }, + ], + }; + }); + setDirty(true); + setMessage(null); + } + + function removeDefaultApp(index: number) { + setPolicy(prev => ({ + ...prev, + defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index), + })); + setDirty(true); + setMessage(null); + } + + /** Rail order follows this list, so admins need to be able to reorder it. */ + function moveDefaultApp(index: number, delta: number) { + setPolicy(prev => { + const apps = [...(prev.defaultSidebarApps ?? [])]; + const target = index + delta; + if (target < 0 || target >= apps.length) return prev; + [apps[index], apps[target]] = [apps[target], apps[index]]; + return { ...prev, defaultSidebarApps: apps }; + }); + setDirty(true); + setMessage(null); + } + function toggleLocked(settingKey: string) { setPolicy(prev => { const existing = prev.restrictions[settingKey] || {}; @@ -182,6 +249,7 @@ export function PolicyTab() { const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; const defaultRelayUrl = resolveDefaultRelayUrl(policy); + const defaultSidebarApps = policy.defaultSidebarApps ?? []; return (
@@ -351,6 +419,167 @@ export function PolicyTab() {
+
+
+

Default Sidebar Apps

+

+ Apps every user gets in the navigation rail without configuring anything. They appear + above the user's own apps and cannot be edited or removed by users. Independent of + the Sidebar Apps feature gate above, which only governs user-added apps. +

+
+
+
+
+ Apps +

+ Entries without a name or a valid http(s) URL are dropped when saving. Inline apps + are framed inside the webmail, so the target must allow being embedded. +

+
+ +
+ + {defaultSidebarApps.length === 0 && ( +

+ No default apps. Users only see the apps they add themselves. +

+ )} + + {defaultSidebarApps.map((app, index) => { + const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; + const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url); + return ( +
+
+
+ + updateDefaultApp(index, { name: e.target.value })} + placeholder="Intranet" + className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+
+ + updateDefaultApp(index, { url: e.target.value })} + placeholder="https://intranet.example.com" + className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${urlInvalid ? 'border-destructive' : 'border-input'}`} + /> + {urlInvalid && ( +

Enter a valid http or https URL

+ )} +
+
+ +
+ updateDefaultApp(index, { icon: e.target.value })} + placeholder="Globe" + className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + {AppIcon ? : ?} + +
+
+
+ +
+
+ + +
+ +
+ + + +
+
+
+ ); + })} +
+
+ {categories.map(category => (
diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 79b68bd2c..a858cf79d 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -46,9 +46,16 @@ export async function PUT(request: NextRequest) { if (policy.themePolicy && typeof policy.themePolicy !== 'object') { return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 }); } + if (policy.defaultSidebarApps !== undefined && !Array.isArray(policy.defaultSidebarApps)) { + return NextResponse.json({ error: 'defaultSidebarApps must be an array' }, { status: 400 }); + } await configManager.setPolicy(policy); - await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip); + await auditLog('policy.update', { + restrictionCount: Object.keys(policy.restrictions || {}).length, + // Entries the sanitizer dropped never reach users, so log what stuck. + defaultSidebarAppCount: configManager.getPolicy().defaultSidebarApps?.length ?? 0, + }, ip); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 5cbeaf7c1..44ce83e0c 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -13,6 +13,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; +import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; @@ -202,13 +203,14 @@ export function NavigationRail({ const client = useAuthStore((s) => s.client); const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; - const sidebarApps = useSettingsStore((s) => s.sidebarApps); const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); - const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; + // Operator-provided apps (#931) plus the user's own; the gate above only + // removes the user's, so a pinned set still shows when custom apps are off. + const visibleSidebarApps = useResolvedSidebarApps(); const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const hasUpdate = useUpdateStore(selectHasUpdate); diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx index 3301997a5..ee58481d8 100644 --- a/components/layout/sidebar-apps-modal.tsx +++ b/components/layout/sidebar-apps-modal.tsx @@ -2,13 +2,14 @@ import { useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; -import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react'; +import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from 'lucide-react'; import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; import { cn, generateUUID } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { IconPicker } from './icon-picker'; import { useSettingsStore, type SidebarApp } from '@/stores/settings-store'; +import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -181,6 +182,52 @@ function SidebarAppForm({ ); } +/** + * Apps the administrator pinned for everyone (#931): shown for context, with + * no edit or delete controls - they come from the policy, not user settings. + */ +function ManagedAppsList() { + const t = useTranslations('sidebar_apps'); + const managedApps = useManagedSidebarApps(); + + if (managedApps.length === 0) return null; + + return ( +
+

{t('managed_title')}

+

{t('managed_description')}

+
+ {managedApps.map((app) => { + const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; + return ( +
+
+ {AppIcon ? : null} +
+
+

{app.name}

+

{app.url}

+
+ + + {t('managed_badge')} + + + {app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')} + +
+ ); + })} +
+
+ ); +} + interface SidebarAppsModalProps { isOpen: boolean; onClose: () => void; @@ -258,6 +305,8 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) { {/* Content */}
+ + {/* Create form */} {isCreating && (
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index f33b28ca7..aa279e619 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -84,6 +84,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; +import { useResolvedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useIsFocusedProTab } from '@/hooks/use-pane-context'; import { ResizeHandle } from '@/components/layout/resize-handle'; @@ -453,7 +454,8 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) { const messages = useMessages() as Record; const installedPlugins = usePluginStore((s) => s.plugins); const installedThemes = useThemeStore((s) => s.installedThemes); - const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); + // Admin-pinned apps are searchable too - users can't add them, but they're listed. + const sidebarAppsList = useResolvedSidebarApps(); const proInterface = useSettingsStore((s) => s.proInterface); // When set, the settings panel is scoped to a shared/group account: a reduced diff --git a/components/settings/sidebar-apps-settings.tsx b/components/settings/sidebar-apps-settings.tsx index 919e8ffcf..cea392b86 100644 --- a/components/settings/sidebar-apps-settings.tsx +++ b/components/settings/sidebar-apps-settings.tsx @@ -2,13 +2,14 @@ import { useState, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical } from "lucide-react"; +import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical, Lock } from "lucide-react"; import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section"; import { IconPicker } from "@/components/layout/icon-picker"; import { useSettingsStore, type SidebarApp } from "@/stores/settings-store"; +import { useManagedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { cn, generateUUID } from "@/lib/utils"; @@ -180,6 +181,52 @@ function AppForm({ ); } +/** + * Apps the administrator pinned for everyone (#931). Listed so users can see + * where the extra rail entries come from, but with no edit or delete controls - + * they live in the admin policy, not in the user's settings. + */ +function ManagedAppsSection() { + const t = useTranslations("settings.sidebar_apps"); + const tApps = useTranslations("sidebar_apps"); + const managedApps = useManagedSidebarApps(); + + if (managedApps.length === 0) return null; + + return ( + +
+ {managedApps.map((app) => { + const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; + return ( +
+
+ {AppIcon ? : null} +
+
+
{app.name}
+
{app.url}
+
+ + + {tApps("managed_badge")} + + + {app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")} + +
+ ); + })} +
+
+ ); +} + export function SidebarAppsSettings() { const t = useTranslations("settings.sidebar_apps"); const tApps = useTranslations("sidebar_apps"); @@ -251,6 +298,8 @@ export function SidebarAppsSettings() { + +
{sidebarApps.length === 0 && !showAddForm && ( diff --git a/hooks/use-resolved-sidebar-apps.ts b/hooks/use-resolved-sidebar-apps.ts new file mode 100644 index 000000000..772b190a7 --- /dev/null +++ b/hooks/use-resolved-sidebar-apps.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useMemo } from 'react'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { mergeSidebarApps, sanitizeDefaultSidebarApps, type ResolvedSidebarApp } from '@/lib/sidebar-apps'; +import type { AdminSidebarApp } from '@/lib/admin/types'; + +/** The apps the operator pins for every user (#931), validated. */ +export function useManagedSidebarApps(): AdminSidebarApp[] { + const defaults = usePolicyStore((s) => s.policy.defaultSidebarApps); + return useMemo(() => sanitizeDefaultSidebarApps(defaults), [defaults]); +} + +/** + * The full sidebar app list: operator-provided apps first, then the user's own. + * + * `sidebarAppsEnabled` gates only the user's custom apps, so turning it off + * still leaves the operator's set in place. + */ +export function useResolvedSidebarApps(): ResolvedSidebarApp[] { + const userApps = useSettingsStore((s) => s.sidebarApps); + const defaults = usePolicyStore((s) => s.policy.defaultSidebarApps); + const userAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); + return useMemo( + () => mergeSidebarApps(defaults, userAppsEnabled ? userApps : []), + [defaults, userApps, userAppsEnabled] + ); +} diff --git a/hooks/use-sidebar-apps.ts b/hooks/use-sidebar-apps.ts index a65d7ab1e..b198a8f7a 100644 --- a/hooks/use-sidebar-apps.ts +++ b/hooks/use-sidebar-apps.ts @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useSettingsStore } from '@/stores/settings-store'; +import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { appUrlToFrameOrigin, inlineAppFrameOrigins } from '@/lib/security/app-frame-origins'; import { readAppFrameOrigins, writeAppFrameOrigins } from '@/lib/security/app-frame-origins-client'; @@ -35,12 +36,18 @@ export function useSidebarApps() { const [loadedApps, setLoadedApps] = useState([]); const keepAppsLoaded = useSettingsStore((s) => s.keepAppsLoaded); const sidebarApps = useSettingsStore((s) => s.sidebarApps); + const managedApps = useManagedSidebarApps(); // The `frame-src` origins this document's CSP was built from. The header is // fixed for the life of the document, so this snapshot - taken before we // write any update - is what the browser will actually allow to be framed. const appliedOriginsRef = useRef(null); + // Admin-pinned apps are in `frame-src` already - the proxy reads them from + // the policy on every document request - so they never need the cookie, and + // opening one must not trigger the reload below. + const managedOrigins = useMemo(() => inlineAppFrameOrigins(managedApps), [managedApps]); + const openInlineApp = useCallback((app: InlineAppState) => { setInlineApp(app); setLoadedApps((prev) => (prev.some((a) => a.id === app.id) ? prev : [...prev, app])); @@ -70,7 +77,7 @@ export function useSidebarApps() { const handleInlineApp = useCallback((appId: string, url: string, name: string) => { const app = { id: appId, url, name }; const origin = appUrlToFrameOrigin(url); - const applied = appliedOriginsRef.current ?? readAppFrameOrigins(); + const applied = [...(appliedOriginsRef.current ?? readAppFrameOrigins()), ...managedOrigins]; // Newly added app (or one added in another tab): its origin isn't in this // document's frame-src, so the iframe would be blocked. Publish it and @@ -91,7 +98,7 @@ export function useSidebarApps() { } openInlineApp(app); - }, [sidebarApps, openInlineApp]); + }, [sidebarApps, managedOrigins, openInlineApp]); const closeInlineApp = useCallback(() => { if (!keepAppsLoaded) { diff --git a/lib/__tests__/sidebar-apps.test.ts b/lib/__tests__/sidebar-apps.test.ts new file mode 100644 index 000000000..02d56f5fa --- /dev/null +++ b/lib/__tests__/sidebar-apps.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SIDEBAR_APP_ID_PREFIX, + MAX_DEFAULT_SIDEBAR_APPS, + mergeSidebarApps, + sanitizeDefaultSidebarApps, +} from '@/lib/sidebar-apps'; +import { inlineAppFrameOrigins } from '@/lib/security/app-frame-origins'; + +const app = (over: Record = {}) => ({ + id: 'admin-app-1', + name: 'Intranet', + url: 'https://intranet.example.com', + icon: 'Globe', + openMode: 'tab', + showOnMobile: false, + ...over, +}); + +describe('sanitizeDefaultSidebarApps', () => { + it('keeps a well-formed app as-is', () => { + expect(sanitizeDefaultSidebarApps([app()])).toEqual([ + { + id: 'admin-app-1', + name: 'Intranet', + url: 'https://intranet.example.com', + icon: 'Globe', + openMode: 'tab', + showOnMobile: false, + }, + ]); + }); + + it('returns an empty list for anything that is not an array', () => { + expect(sanitizeDefaultSidebarApps(undefined)).toEqual([]); + expect(sanitizeDefaultSidebarApps(null)).toEqual([]); + expect(sanitizeDefaultSidebarApps({})).toEqual([]); + expect(sanitizeDefaultSidebarApps('https://example.com')).toEqual([]); + }); + + it('drops entries without a usable name or URL', () => { + expect(sanitizeDefaultSidebarApps([ + app({ name: ' ' }), + app({ name: 42 }), + app({ url: '' }), + app({ url: 'not a url' }), + app({ url: 'javascript:alert(1)' }), + app({ url: 'data:text/html,

x

' }), + app({ url: `https://example.com/${'a'.repeat(2100)}` }), + null, + 'nope', + ])).toEqual([]); + }); + + it('keeps the good entries when one is malformed', () => { + const result = sanitizeDefaultSidebarApps([app({ id: 'a', url: 'nope' }), app({ id: 'b' })]); + expect(result.map((a) => a.name)).toEqual(['Intranet']); + expect(result[0].id).toBe('admin-app-b'); + }); + + it('falls back to a known icon for anything Lucide cannot resolve', () => { + expect(sanitizeDefaultSidebarApps([app({ icon: 'Globe/../../etc' })])[0].icon).toBe('Globe'); + expect(sanitizeDefaultSidebarApps([app({ icon: '' })])[0].icon).toBe('Globe'); + expect(sanitizeDefaultSidebarApps([app({ icon: 12 })])[0].icon).toBe('Globe'); + expect(sanitizeDefaultSidebarApps([app({ icon: 'Rss' })])[0].icon).toBe('Rss'); + }); + + it('coerces openMode and showOnMobile', () => { + const [tab, inline] = sanitizeDefaultSidebarApps([ + app({ id: 'a', openMode: 'popup', showOnMobile: 'yes' }), + app({ id: 'b', openMode: 'inline', showOnMobile: true }), + ]); + expect(tab.openMode).toBe('tab'); + expect(tab.showOnMobile).toBe(false); + expect(inline.openMode).toBe('inline'); + expect(inline.showOnMobile).toBe(true); + }); + + it('namespaces ids so a hand-written policy cannot shadow a user app', () => { + const [first, second] = sanitizeDefaultSidebarApps([ + app({ id: 'app-user-owned' }), + app({ id: '../../evil id' }), + ]); + expect(first.id.startsWith(DEFAULT_SIDEBAR_APP_ID_PREFIX)).toBe(true); + expect(second.id).toMatch(/^admin-app-[a-zA-Z0-9_-]*$/); + }); + + it('de-duplicates ids so React keys stay unique', () => { + const ids = sanitizeDefaultSidebarApps([app({ id: 'x' }), app({ id: 'x' }), app({ id: 'x' })]) + .map((a) => a.id); + expect(new Set(ids).size).toBe(3); + }); + + it('gives an entry with no id one derived from its position', () => { + const [only] = sanitizeDefaultSidebarApps([app({ id: undefined })]); + expect(only.id).toBe('admin-app-1'); + }); + + it('caps the list', () => { + const many = Array.from({ length: MAX_DEFAULT_SIDEBAR_APPS + 5 }, (_, i) => app({ id: `a${i}` })); + expect(sanitizeDefaultSidebarApps(many)).toHaveLength(MAX_DEFAULT_SIDEBAR_APPS); + }); + + it('trims a name to the length the admin form allows', () => { + expect(sanitizeDefaultSidebarApps([app({ name: 'x'.repeat(80) })])[0].name).toHaveLength(50); + }); +}); + +describe('mergeSidebarApps', () => { + const userApp = { + id: 'app-abc', + name: 'My app', + url: 'https://mine.example.com', + icon: 'Rss', + openMode: 'tab' as const, + showOnMobile: false, + }; + + it('lists admin apps first and flags them managed', () => { + const merged = mergeSidebarApps([app()], [userApp]); + expect(merged.map((a) => a.id)).toEqual(['admin-app-1', 'app-abc']); + expect(merged[0].managed).toBe(true); + expect(merged[1].managed).toBeUndefined(); + }); + + it('works with either side missing', () => { + expect(mergeSidebarApps(undefined, [userApp]).map((a) => a.id)).toEqual(['app-abc']); + expect(mergeSidebarApps([app()], undefined).map((a) => a.id)).toEqual(['admin-app-1']); + expect(mergeSidebarApps(null, null)).toEqual([]); + }); + + it('lets an admin app win an id collision', () => { + const merged = mergeSidebarApps([app({ id: 'admin-app-1' })], [{ ...userApp, id: 'admin-app-1' }]); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe('Intranet'); + }); + + it('preserves the admin-configured order', () => { + const merged = mergeSidebarApps( + [app({ id: 'b', name: 'B' }), app({ id: 'a', name: 'A' })], + [userApp] + ); + expect(merged.map((a) => a.name)).toEqual(['B', 'A', 'My app']); + }); +}); + +describe('CSP origins for admin apps', () => { + it('covers only the inline ones, which are the ones that get framed', () => { + const apps = sanitizeDefaultSidebarApps([ + app({ id: 'a', url: 'https://framed.example.com/app', openMode: 'inline' }), + app({ id: 'b', url: 'https://newtab.example.com', openMode: 'tab' }), + ]); + expect(inlineAppFrameOrigins(apps)).toEqual(['https://framed.example.com']); + }); +}); diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index 24efde4e4..fcafadb06 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -4,6 +4,7 @@ import { readFileEnv } from '@/lib/read-file-env'; import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types'; import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; import { isValidRelayUrl, normalizeRelayUrl } from '@/lib/push-relays'; +import { sanitizeDefaultSidebarApps } from '@/lib/sidebar-apps'; function parseEnvValue(value: string, type: string): unknown { switch (type) { @@ -196,6 +197,9 @@ class ConfigManager { .filter(relay => isValidRelayUrl(relay.url)); const defaultRelay = normalizeRelayUrl(policy.pushRelayUrl); policy.pushRelayUrl = isValidRelayUrl(defaultRelay) ? defaultRelay : ''; + // Operator-pinned sidebar apps reach every user's rail, and policy.json can + // be hand-edited, so the list is validated here rather than at render time. + policy.defaultSidebarApps = sanitizeDefaultSidebarApps(policy.defaultSidebarApps); return policy; } diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 2ea466cdb..b902dc68c 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -117,6 +117,23 @@ export interface PushRelayOption { url: string; } +/** + * A sidebar app the operator ships to every user (#931). Same shape as the + * user's own `SidebarApp`, but the id is issued by the admin UI and the entry + * is read-only in the client - users see it in the rail without configuring + * anything, and cannot edit or delete it. + */ +export interface AdminSidebarApp { + id: string; + name: string; + url: string; + /** Lucide icon name (e.g. 'Globe'). */ + icon: string; + /** Open in a new browser tab, or embedded in an iframe. */ + openMode: 'tab' | 'inline'; + showOnMobile: boolean; +} + export interface SettingsPolicy { restrictions: Record; features: FeatureGates; @@ -137,6 +154,13 @@ export interface SettingsPolicy { pushRelayUrl?: string; /** When true, users are pinned to pushRelayUrl and cannot pick another relay. */ pushRelayUrlLocked?: boolean; + /** + * Sidebar apps shown to every user, ahead of their own. Read-only in the + * client; sanitized on policy load and save. Independent of the + * `sidebarAppsEnabled` gate, which only governs *user-added* apps - an + * operator can ship a fixed set while forbidding custom ones. + */ + defaultSidebarApps?: AdminSidebarApp[]; } export const DEFAULT_POLICY: SettingsPolicy = { @@ -150,6 +174,7 @@ export const DEFAULT_POLICY: SettingsPolicy = { pushRelays: [], pushRelayUrl: '', pushRelayUrlLocked: false, + defaultSidebarApps: [], }; export interface AuditEntry { diff --git a/lib/sidebar-apps.ts b/lib/sidebar-apps.ts new file mode 100644 index 000000000..058f10342 --- /dev/null +++ b/lib/sidebar-apps.ts @@ -0,0 +1,112 @@ +/** + * Admin-provided sidebar apps (#931) and how they combine with the user's own. + * + * An operator can pin a set of apps in the admin dashboard so every user gets + * them in the navigation rail without configuring anything. They are stored in + * the settings policy, so they arrive with the policy the client already + * fetches, and they are read-only there - the merge below marks them `managed` + * so the UI can render them without edit/delete affordances. + * + * Everything a user could see here comes from the policy file, which an + * operator can also edit by hand, so entries are sanitized on the way in + * rather than trusted: a malformed app is dropped, not rendered. + */ + +import type { AdminSidebarApp } from '@/lib/admin/types'; + +/** Cap on how many apps an operator can pin, matching the CSP origin budget. */ +export const MAX_DEFAULT_SIDEBAR_APPS = 20; + +/** Prefix every admin app id carries, so it can never collide with a user's. */ +export const DEFAULT_SIDEBAR_APP_ID_PREFIX = 'admin-app-'; + +const MAX_APP_NAME_LENGTH = 50; +const MAX_APP_URL_LENGTH = 2048; + +/** Lucide exports icons as PascalCase identifiers; anything else can't resolve. */ +const ICON_NAME_RE = /^[A-Za-z][A-Za-z0-9]{0,63}$/; + +const ID_SUFFIX_RE = /[^a-zA-Z0-9_-]/g; + +/** A sidebar app as rendered: the user's own, or one pinned by the operator. */ +export interface ResolvedSidebarApp extends AdminSidebarApp { + /** True for admin-provided apps, which the user cannot edit or remove. */ + managed?: boolean; +} + +function sanitizeUrl(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const url = raw.trim(); + if (!url || url.length > MAX_APP_URL_LENGTH) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null; + if (!parsed.host) return null; + return url; +} + +/** + * Validates the operator's app list. Bad entries are dropped individually so a + * single typo never blanks out the rest, and ids are forced into the admin + * namespace so a hand-written policy can't shadow a user's own app. + */ +export function sanitizeDefaultSidebarApps(raw: unknown): AdminSidebarApp[] { + if (!Array.isArray(raw)) return []; + const seenIds = new Set(); + const out: AdminSidebarApp[] = []; + + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const app = entry as Partial; + + const name = typeof app.name === 'string' ? app.name.trim().slice(0, MAX_APP_NAME_LENGTH) : ''; + if (!name) continue; + + const url = sanitizeUrl(app.url); + if (!url) continue; + + const icon = typeof app.icon === 'string' && ICON_NAME_RE.test(app.icon) ? app.icon : 'Globe'; + + // Ids only have to be stable and unique; the shape is ours to impose. + const rawId = typeof app.id === 'string' ? app.id.replace(ID_SUFFIX_RE, '') : ''; + const base = rawId.startsWith(DEFAULT_SIDEBAR_APP_ID_PREFIX) + ? rawId + : `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${rawId || out.length + 1}`; + let id = base; + for (let n = 2; seenIds.has(id); n++) id = `${base}-${n}`; + seenIds.add(id); + + out.push({ + id, + name, + url, + icon, + openMode: app.openMode === 'inline' ? 'inline' : 'tab', + showOnMobile: app.showOnMobile === true, + }); + if (out.length >= MAX_DEFAULT_SIDEBAR_APPS) break; + } + + return out; +} + +/** + * The rail's app list: operator-provided apps first, then the user's own. + * + * Callers pass an empty user list when the `sidebarAppsEnabled` gate is off - + * that gate governs custom apps only, so an operator's own set survives it. + */ +export function mergeSidebarApps( + // Untyped on purpose: this is the raw policy value, sanitized below. + defaults: unknown, + userApps: readonly AdminSidebarApp[] | undefined | null +): ResolvedSidebarApp[] { + const managed = sanitizeDefaultSidebarApps(defaults); + const seen = new Set(managed.map((app) => app.id)); + const own = (Array.isArray(userApps) ? userApps : []).filter((app) => app && !seen.has(app.id)); + return [...managed.map((app) => ({ ...app, managed: true })), ...own]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 14e758ca4..027949b9c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -202,6 +202,9 @@ "delete_confirm": "هل أنت متأكد أنك تريد حذف \"{name}\"؟", "no_apps": "لم تتم إضافة أي تطبيقات بعد", "no_apps_hint": "أضف تطبيقات وروابط مخصصة إلى الشريط الجانبي", + "managed_title": "مقدَّمة من المسؤول", + "managed_description": "تم إعداد هذه التطبيقات للجميع ولا يمكن تغييرها.", + "managed_badge": "مُدارة", "search_icons": "بحث عن أيقونات...", "show_popular": "الأكثر شيوعًا", "show_all": "الكل", @@ -1867,6 +1870,8 @@ "description": "إدارة التطبيقات والروابط المخصصة في الشريط الجانبي", "keep_loaded": "إبقاء التطبيقات محمّلة", "keep_loaded_description": "إبقاء التطبيقات الداخلية تعمل في الخلفية عند التبديل بينها لتجنب إعادة التحميل", + "managed_title": "مقدَّمة من المسؤول", + "managed_description": "تم إعداد هذه التطبيقات للجميع ولا يمكن تغييرها.", "manage_title": "التطبيقات المخصصة", "manage_description": "إضافة أو تعديل أو إزالة التطبيقات المخصصة من الشريط الجانبي" }, diff --git a/locales/ca/common.json b/locales/ca/common.json index 8caa7b953..b37db406b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Segur que voleu suprimir «{name}»?", "no_apps": "Encara no s'ha afegit cap aplicació", "no_apps_hint": "Afegiu aplicacions i enllaços personalitzats a la barra lateral", + "managed_title": "Proporcionades per l'administració", + "managed_description": "Aquestes aplicacions estan configurades per a tothom i no es poden canviar.", + "managed_badge": "Gestionada", "search_icons": "Cerca icones...", "show_popular": "Populars", "show_all": "Totes", @@ -1867,6 +1870,8 @@ "description": "Gestioneu aplicacions i enllaços personalitzats a la barra lateral", "keep_loaded": "Mantén les aplicacions carregades", "keep_loaded_description": "Manté les aplicacions incrustades en execució en segon pla en canviar entre elles per evitar recarregar-les", + "managed_title": "Proporcionades per l'administració", + "managed_description": "Aquestes aplicacions estan configurades per a tothom i no es poden canviar.", "manage_title": "Aplicacions personalitzades", "manage_description": "Afegiu, editeu o elimineu aplicacions personalitzades de la barra lateral" }, diff --git a/locales/cs/common.json b/locales/cs/common.json index 036339f40..dc3ee6ce5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Opravdu chcete odstranit \"{name}\"?", "no_apps": "Zatím nebyly přidány žádné aplikace", "no_apps_hint": "Přidejte vlastní aplikace a odkazy do postranního panelu", + "managed_title": "Poskytnuto správcem", + "managed_description": "Tyto aplikace jsou nastaveny pro všechny a nelze je změnit.", + "managed_badge": "Spravováno", "search_icons": "Hledat ikony...", "show_popular": "Oblíbené", "show_all": "Všechny", @@ -1860,6 +1863,8 @@ "description": "Spravujte vlastní aplikace a odkazy v postranním panelu", "keep_loaded": "Ponechat aplikace načtené", "keep_loaded_description": "Udržovat vložené aplikace spuštěné na pozadí během přepínání, aby se předešlo jejich opětovnému načítání", + "managed_title": "Poskytnuto správcem", + "managed_description": "Tyto aplikace jsou nastaveny pro všechny a nelze je změnit.", "manage_title": "Vlastní aplikace", "manage_description": "Přidávat, upravovat nebo odebírat vlastní aplikace z postranního panelu" }, diff --git a/locales/da/common.json b/locales/da/common.json index ef8288b94..621dffcd0 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Er du sikker på, at du vil slette \\\"{name}\\\"?", "no_apps": "Ingen apps tilføjet endnu", "no_apps_hint": "Tilføj brugerdefinerede apps og links til dit sidepanel", + "managed_title": "Leveret af din administrator", + "managed_description": "Disse apps er sat op for alle og kan ikke ændres.", + "managed_badge": "Administreret", "search_icons": "Søg efter ikoner...", "show_popular": "Populære", "show_all": "Alle", @@ -1863,6 +1866,8 @@ "description": "Administrér brugerdefinerede apps og links i dit sidepanel", "keep_loaded": "Hold apps indlæst", "keep_loaded_description": "Hold indlejrede apps kørende i baggrunden, når du skifter mellem dem, for at undgå genindlæsning", + "managed_title": "Leveret af din administrator", + "managed_description": "Disse apps er sat op for alle og kan ikke ændres.", "manage_title": "Brugerdefinerede apps", "manage_description": "Tilføj, redigér eller fjern brugerdefinerede apps fra dit sidepanel" }, diff --git a/locales/de/common.json b/locales/de/common.json index c1823cf54..e72ed7d6f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Möchten Sie \"{name}\" wirklich löschen?", "no_apps": "Noch keine Apps hinzugefügt", "no_apps_hint": "Fügen Sie benutzerdefinierte Apps und Links zur Seitenleiste hinzu", + "managed_title": "Von Ihrer Administration bereitgestellt", + "managed_description": "Diese Apps sind für alle eingerichtet und können nicht geändert werden.", + "managed_badge": "Verwaltet", "search_icons": "Symbole suchen...", "show_popular": "Beliebt", "show_all": "Alle", @@ -1860,6 +1863,8 @@ "description": "Benutzerdefinierte Apps und Links in der Seitenleiste verwalten", "keep_loaded": "Apps geladen halten", "keep_loaded_description": "Inline-Apps beim Wechsel im Hintergrund laufen lassen, um Neuladen zu vermeiden", + "managed_title": "Von Ihrer Administration bereitgestellt", + "managed_description": "Diese Apps sind für alle eingerichtet und können nicht geändert werden.", "manage_title": "Benutzerdefinierte Apps", "manage_description": "Apps in der Seitenleiste hinzufügen, bearbeiten oder entfernen" }, diff --git a/locales/en/common.json b/locales/en/common.json index 81fdc62c1..60bc1ab2d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Are you sure you want to delete \"{name}\"?", "no_apps": "No apps added yet", "no_apps_hint": "Add custom apps and links to your sidebar", + "managed_title": "Provided by your administrator", + "managed_description": "These apps are set up for everyone and cannot be changed.", + "managed_badge": "Managed", "search_icons": "Search icons...", "show_popular": "Popular", "show_all": "All", @@ -1867,6 +1870,8 @@ "description": "Manage custom apps and links in your sidebar", "keep_loaded": "Keep Apps Loaded", "keep_loaded_description": "Keep inline apps running in the background when switching between them to avoid reloading", + "managed_title": "Provided by your administrator", + "managed_description": "These apps are set up for everyone and cannot be changed.", "manage_title": "Custom Apps", "manage_description": "Add, edit, or remove custom apps from your sidebar" }, diff --git a/locales/es/common.json b/locales/es/common.json index cde3dd74b..1da05735e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -202,6 +202,9 @@ "delete_confirm": "¿Estás seguro de que quieres eliminar \"{name}\"?", "no_apps": "No se han añadido aplicaciones", "no_apps_hint": "Añade aplicaciones y enlaces personalizados a tu barra lateral", + "managed_title": "Proporcionadas por tu administrador", + "managed_description": "Estas aplicaciones están configuradas para todos y no se pueden cambiar.", + "managed_badge": "Gestionada", "search_icons": "Buscar iconos...", "show_popular": "Populares", "show_all": "Todos", @@ -1860,6 +1863,8 @@ "description": "Gestionar aplicaciones y enlaces personalizados en la barra lateral", "keep_loaded": "Mantener apps cargadas", "keep_loaded_description": "Mantener las apps en línea ejecutándose en segundo plano al cambiar entre ellas para evitar recargas", + "managed_title": "Proporcionadas por tu administrador", + "managed_description": "Estas aplicaciones están configuradas para todos y no se pueden cambiar.", "manage_title": "Apps personalizadas", "manage_description": "Agregar, editar o eliminar apps personalizadas de la barra lateral" }, diff --git a/locales/fa/common.json b/locales/fa/common.json index 508ab00f1..dd71e891d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -202,6 +202,9 @@ "delete_confirm": "آیا از حذف \"{name}\" اطمینان دارید؟", "no_apps": "هنوز برنامه‌ای اضافه نشده", "no_apps_hint": "برنامه‌ها و لینک‌های دلخواه را به نوار کناری اضافه کنید", + "managed_title": "ارائه‌شده توسط مدیر شما", + "managed_description": "این برنامه‌ها برای همه تنظیم شده‌اند و قابل تغییر نیستند.", + "managed_badge": "مدیریت‌شده", "search_icons": "جستجوی آیکون‌ها...", "show_popular": "محبوب", "show_all": "همه", @@ -1867,6 +1870,8 @@ "description": "مدیریت برنامه‌ها و لینک‌های سفارشی در نوار کناری", "keep_loaded": "نگه داشتن برنامه‌ها در حالت بارگذاری", "keep_loaded_description": "برنامه‌های inline هنگام تعویض بین آنها در پس‌زمینه فعال بمانند تا از بارگذاری مجدد جلوگیری شود", + "managed_title": "ارائه‌شده توسط مدیر شما", + "managed_description": "این برنامه‌ها برای همه تنظیم شده‌اند و قابل تغییر نیستند.", "manage_title": "برنامه‌های سفارشی", "manage_description": "افزودن، ویرایش یا حذف برنامه‌های سفارشی از نوار کناری" }, diff --git a/locales/fr/common.json b/locales/fr/common.json index c3ae497b9..13a9f07a3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?", "no_apps": "Aucune application ajoutée", "no_apps_hint": "Ajoutez des applications et liens personnalisés à votre barre latérale", + "managed_title": "Fournies par votre administrateur", + "managed_description": "Ces applications sont configurées pour tout le monde et ne peuvent pas être modifiées.", + "managed_badge": "Gérée", "search_icons": "Rechercher des icônes...", "show_popular": "Populaires", "show_all": "Toutes", @@ -1860,6 +1863,8 @@ "description": "Gérer les applications et liens personnalisés dans la barre latérale", "keep_loaded": "Garder les apps chargées", "keep_loaded_description": "Garder les apps en ligne en arrière-plan lors du basculement pour éviter le rechargement", + "managed_title": "Fournies par votre administrateur", + "managed_description": "Ces applications sont configurées pour tout le monde et ne peuvent pas être modifiées.", "manage_title": "Apps personnalisées", "manage_description": "Ajouter, modifier ou supprimer des apps personnalisées de la barre latérale" }, diff --git a/locales/he/common.json b/locales/he/common.json index 5ff1e464f..63b0d14f2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -167,6 +167,9 @@ "delete_confirm": "האם אתה בטוח שברצונך למחוק את \"{name}\"?", "no_apps": "עדיין לא נוספו אפליקציות", "no_apps_hint": "הוסף אפליקציות וקישורים מותאמים אישית לסרגל הצד שלך", + "managed_title": "סופקו על ידי מנהל המערכת", + "managed_description": "אפליקציות אלו מוגדרות עבור כולם ולא ניתן לשנות אותן.", + "managed_badge": "מנוהלת", "search_icons": "חיפוש אייקונים...", "show_popular": "פּוֹפּוּלָרִי", "show_all": "כֹּל", @@ -1826,6 +1829,8 @@ "description": "נהל אפליקציות וקישורים מותאמים אישית בסרגל הצד שלך", "keep_loaded": "השאר אפליקציות טעונות", "keep_loaded_description": "השאר אפליקציות מוטבעות פועלות ברקע בעת המעבר ביניהן כדי למנוע טעינה מחדש", + "managed_title": "סופקו על ידי מנהל המערכת", + "managed_description": "אפליקציות אלו מוגדרות עבור כולם ולא ניתן לשנות אותן.", "manage_title": "אפליקציות מותאמות אישית", "manage_description": "הוסף, ערוך או הסר אפליקציות מותאמות אישית מהסרגל הצדדי שלך" }, diff --git a/locales/hu/common.json b/locales/hu/common.json index 194fa0142..79d9a4573 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Biztosan törölni szeretnéd a \"{name}\" alkalmazást?", "no_apps": "Még nincsenek alkalmazások", "no_apps_hint": "Egyedi alkalmazások és hivatkozások hozzáadása az oldalsávhoz", + "managed_title": "A rendszergazda biztosítja", + "managed_description": "Ezeket az alkalmazásokat mindenki számára beállították, és nem módosíthatók.", + "managed_badge": "Felügyelt", "search_icons": "Ikonok keresése...", "show_popular": "Népszerű", "show_all": "Összes", @@ -1863,6 +1866,8 @@ "description": "Egyedi alkalmazások és hivatkozások kezelése az oldalsávon", "keep_loaded": "Alkalmazások betöltve tartása", "keep_loaded_description": "Beágyazott alkalmazások futtatása a háttérben váltáskor az újratöltés elkerülése érdekében", + "managed_title": "A rendszergazda biztosítja", + "managed_description": "Ezeket az alkalmazásokat mindenki számára beállították, és nem módosíthatók.", "manage_title": "Egyedi alkalmazások", "manage_description": "Egyedi alkalmazások hozzáadása, szerkesztése vagy eltávolítása az oldalsávról" }, diff --git a/locales/it/common.json b/locales/it/common.json index b4c815368..c2ec7c017 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Sei sicuro di voler eliminare \"{name}\"?", "no_apps": "Nessuna app aggiunta", "no_apps_hint": "Aggiungi app e link personalizzati alla barra laterale", + "managed_title": "Fornite dall'amministratore", + "managed_description": "Queste app sono configurate per tutti e non possono essere modificate.", + "managed_badge": "Gestita", "search_icons": "Cerca icone...", "show_popular": "Popolari", "show_all": "Tutte", @@ -1860,6 +1863,8 @@ "description": "Gestisci app e link personalizzati nella barra laterale", "keep_loaded": "Mantieni app caricate", "keep_loaded_description": "Mantieni le app inline in esecuzione in background durante il passaggio per evitare il ricaricamento", + "managed_title": "Fornite dall'amministratore", + "managed_description": "Queste app sono configurate per tutti e non possono essere modificate.", "manage_title": "App personalizzate", "manage_description": "Aggiungi, modifica o rimuovi app personalizzate dalla barra laterale" }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 40bd8ab5a..d00142e80 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -202,6 +202,9 @@ "delete_confirm": "「{name}」を削除してもよろしいですか?", "no_apps": "アプリが追加されていません", "no_apps_hint": "カスタムアプリやリンクをサイドバーに追加できます", + "managed_title": "管理者によって提供", + "managed_description": "これらのアプリは全員向けに設定されており、変更できません。", + "managed_badge": "管理対象", "search_icons": "アイコンを検索...", "show_popular": "人気", "show_all": "すべて", @@ -1860,6 +1863,8 @@ "description": "サイドバーのカスタムアプリとリンクを管理", "keep_loaded": "アプリを読み込んだまま保持", "keep_loaded_description": "切り替え時にインラインアプリをバックグラウンドで実行し続けて再読み込みを回避", + "managed_title": "管理者によって提供", + "managed_description": "これらのアプリは全員向けに設定されており、変更できません。", "manage_title": "カスタムアプリ", "manage_description": "サイドバーのカスタムアプリを追加、編集、削除" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 9c99f2fc1..439a2826c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -202,6 +202,9 @@ "delete_confirm": "정말 \"{name}\" 앱을 삭제할까요?", "no_apps": "아직 추가된 앱이 없어요", "no_apps_hint": "사이드바에 맞춤형 앱과 링크를 추가해 보세요", + "managed_title": "관리자가 제공", + "managed_description": "이 앱은 모든 사용자를 위해 설정되었으며 변경할 수 없습니다.", + "managed_badge": "관리됨", "search_icons": "아이콘 검색...", "show_popular": "인기", "show_all": "전체", @@ -1860,6 +1863,8 @@ "description": "사이드바의 커스텀 앱과 링크를 관리해요", "keep_loaded": "앱 실행 유지", "keep_loaded_description": "인라인 앱을 전환할 때 매번 새로고침되지 않도록 백그라운드에서 실행 상태를 유지해요", + "managed_title": "관리자가 제공", + "managed_description": "이 앱은 모든 사용자를 위해 설정되었으며 변경할 수 없습니다.", "manage_title": "커스텀 앱", "manage_description": "사이드바에 나만의 앱을 추가, 수정, 삭제해 보세요" }, diff --git a/locales/lv/common.json b/locales/lv/common.json index d2e5c9602..317e94667 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Vai tiešām vēlaties dzēst \"{name}\"?", "no_apps": "Lietotnes vēl nav pievienotas", "no_apps_hint": "Pievienojiet pielāgotas lietotnes un saites sānu joslā", + "managed_title": "Nodrošina administrators", + "managed_description": "Šīs lietotnes ir iestatītas visiem, un tās nevar mainīt.", + "managed_badge": "Pārvaldīta", "search_icons": "Meklēt ikonas...", "show_popular": "Populārākās", "show_all": "Visas", @@ -1860,6 +1863,8 @@ "description": "Pārvaldiet pielāgotas lietotnes un saites sānu joslā", "keep_loaded": "Turēt lietotnes ielādētas", "keep_loaded_description": "Turēt iebūvētās lietotnes fonā, lai izvairītos no pārlādēšanas", + "managed_title": "Nodrošina administrators", + "managed_description": "Šīs lietotnes ir iestatītas visiem, un tās nevar mainīt.", "manage_title": "Pielāgotas lietotnes", "manage_description": "Pievienojiet, rediģējiet vai dzēsiet sānu joslas lietotnes" }, diff --git a/locales/mn/common.json b/locales/mn/common.json index a65ce0fff..cceea1929 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу?", "no_apps": "Одоохондоо апп нэмээгүй байна", "no_apps_hint": "Хажуугийн самбартаа захиалгат програм, холбоос нэмнэ үү", + "managed_title": "Администратороос нийлүүлсэн", + "managed_description": "Эдгээр аппыг бүх хэрэглэгчид тохируулсан бөгөөд өөрчлөх боломжгүй.", + "managed_badge": "Удирдсан", "search_icons": "Хайлтын дүрсүүд...", "show_popular": "Алдартай", "show_all": "Бүгд", @@ -1867,6 +1870,8 @@ "description": "Хажуугийн самбар дээрх захиалгат програмууд болон холбоосуудыг удирдах", "keep_loaded": "Апп-уудыг ачаалалтай байлгах", "keep_loaded_description": "Дахин ачаалахаас зайлсхийхийн тулд шугаман програмуудыг хооронд нь сэлгэхдээ ар талд нь ажиллуулаарай", + "managed_title": "Администратороос нийлүүлсэн", + "managed_description": "Эдгээр аппыг бүх хэрэглэгчид тохируулсан бөгөөд өөрчлөх боломжгүй.", "manage_title": "Захиалгат програмууд", "manage_description": "Хажуугийн цэсэндээ тусгай програм нэмэх, засах, устгах боломжтой" }, diff --git a/locales/nb/common.json b/locales/nb/common.json index 9503130fc..fee5432eb 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Er du sikker på at du vil slette «{name}»?", "no_apps": "Ingen apper er lagt til ennå", "no_apps_hint": "Legg til egendefinerte apper og lenker i sidefeltet", + "managed_title": "Levert av administratoren din", + "managed_description": "Disse appene er satt opp for alle og kan ikke endres.", + "managed_badge": "Administrert", "search_icons": "Søk etter ikoner...", "show_popular": "Populære", "show_all": "Alle", @@ -1865,6 +1868,8 @@ "description": "Administrer egendefinerte apper og lenker i sidefeltet", "keep_loaded": "Hold apper åpne i bakgrunnen", "keep_loaded_description": "La apper i sidefeltet fortsette å kjøre i bakgrunnen når du bytter mellom dem, slik at de ikke må lastes inn på nytt", + "managed_title": "Levert av administratoren din", + "managed_description": "Disse appene er satt opp for alle og kan ikke endres.", "manage_title": "Egendefinerte apper", "manage_description": "Legg til, rediger eller fjern egendefinerte apper fra sidefeltet" }, diff --git a/locales/nl/common.json b/locales/nl/common.json index de2194cbd..e3962c6b1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Weet je zeker dat je \"{name}\" wilt verwijderen?", "no_apps": "Nog geen apps toegevoegd", "no_apps_hint": "Voeg aangepaste apps en links toe aan je zijbalk", + "managed_title": "Aangeboden door uw beheerder", + "managed_description": "Deze apps zijn voor iedereen ingesteld en kunnen niet worden gewijzigd.", + "managed_badge": "Beheerd", "search_icons": "Pictogrammen zoeken...", "show_popular": "Populair", "show_all": "Alle", @@ -1860,6 +1863,8 @@ "description": "Aangepaste apps en links in de zijbalk beheren", "keep_loaded": "Apps geladen houden", "keep_loaded_description": "Inline-apps op de achtergrond laten draaien bij het wisselen om herladen te voorkomen", + "managed_title": "Aangeboden door uw beheerder", + "managed_description": "Deze apps zijn voor iedereen ingesteld en kunnen niet worden gewijzigd.", "manage_title": "Aangepaste apps", "manage_description": "Aangepaste apps toevoegen, bewerken of verwijderen uit de zijbalk" }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 83f512859..e38a625b9 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Czy na pewno chcesz usunąć \"{name}\"?", "no_apps": "Nie dodano jeszcze żadnych aplikacji", "no_apps_hint": "Dodaj własne aplikacje i linki do paska bocznego", + "managed_title": "Udostępnione przez administratora", + "managed_description": "Te aplikacje są skonfigurowane dla wszystkich i nie można ich zmienić.", + "managed_badge": "Zarządzana", "search_icons": "Szukaj ikon...", "show_popular": "Popularne", "show_all": "Wszystkie", @@ -1860,6 +1863,8 @@ "description": "Zarządzaj niestandardowymi aplikacjami i linkami na pasku bocznym", "keep_loaded": "Pozostaw aplikacje załadowane", "keep_loaded_description": "Utrzymuj aplikacje wbudowane uruchomione w tle podczas przełączania się między nimi, aby uniknąć ponownego ładowania", + "managed_title": "Udostępnione przez administratora", + "managed_description": "Te aplikacje są skonfigurowane dla wszystkich i nie można ich zmienić.", "manage_title": "Niestandardowe aplikacje", "manage_description": "Dodawaj, edytuj lub usuwaj niestandardowe aplikacje z paska bocznego" }, diff --git a/locales/pt/common.json b/locales/pt/common.json index abd46aa23..7cce29fd9 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Tem certeza de que deseja excluir \"{name}\"?", "no_apps": "Nenhum app adicionado", "no_apps_hint": "Adicione apps e links personalizados à sua barra lateral", + "managed_title": "Fornecidas pelo seu administrador", + "managed_description": "Estas aplicações estão configuradas para todos e não podem ser alteradas.", + "managed_badge": "Gerida", "search_icons": "Pesquisar ícones...", "show_popular": "Populares", "show_all": "Todos", @@ -1860,6 +1863,8 @@ "description": "Gerenciar aplicativos e links personalizados na barra lateral", "keep_loaded": "Manter apps carregados", "keep_loaded_description": "Manter apps inline em execução em segundo plano ao alternar entre eles para evitar recarregamento", + "managed_title": "Fornecidas pelo seu administrador", + "managed_description": "Estas aplicações estão configuradas para todos e não podem ser alteradas.", "manage_title": "Apps personalizados", "manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral" }, diff --git a/locales/ro/common.json b/locales/ro/common.json index 09008faa6..4e4402daf 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Sunteți sigur că doriți să ștergeți „{name}”?", "no_apps": "Nu s-au adăugat încă aplicații", "no_apps_hint": "Adăugați aplicații personalizate și linkuri în bara laterală", + "managed_title": "Furnizate de administrator", + "managed_description": "Aceste aplicații sunt configurate pentru toți și nu pot fi modificate.", + "managed_badge": "Gestionată", "search_icons": "Pictograme de căutare...", "show_popular": "Popular", "show_all": "Toate", @@ -1867,6 +1870,8 @@ "description": "Gestionați aplicațiile personalizate și linkurile din bara laterală", "keep_loaded": "Mențineți aplicațiile încărcate", "keep_loaded_description": "Mențineți aplicațiile încorporate în execuție în fundal atunci când comutați între ele, pentru a evita reîncărcarea", + "managed_title": "Furnizate de administrator", + "managed_description": "Aceste aplicații sunt configurate pentru toți și nu pot fi modificate.", "manage_title": "Aplicații personalizate", "manage_description": "Adăugați, editați sau eliminați aplicații personalizate din bara laterală" }, diff --git a/locales/ru/common.json b/locales/ru/common.json index bd2bfcccd..647e9aa13 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Вы уверены, что хотите удалить \"{name}\"?", "no_apps": "Приложения ещё не добавлены", "no_apps_hint": "Добавьте пользовательские приложения и ссылки в боковую панель", + "managed_title": "Предоставлено администратором", + "managed_description": "Эти приложения настроены для всех и не могут быть изменены.", + "managed_badge": "Управляемое", "search_icons": "Поиск иконок...", "show_popular": "Популярные", "show_all": "Все", @@ -1860,6 +1863,8 @@ "description": "Управляйте пользовательскими приложениями и ссылками в боковой панели", "keep_loaded": "Держать приложения загруженными", "keep_loaded_description": "Держать встроенные приложения в фоне при переключении между ними, чтобы избежать перезагрузки", + "managed_title": "Предоставлено администратором", + "managed_description": "Эти приложения настроены для всех и не могут быть изменены.", "manage_title": "Пользовательские приложения", "manage_description": "Добавляйте, редактируйте и удаляйте пользовательские приложения из боковой панели" }, diff --git a/locales/sk/common.json b/locales/sk/common.json index 32684e09c..07a017b49 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Naozaj chcete odstrániť \"{name}\"?", "no_apps": "Zatiaľ neboli pridané žiadne aplikácie", "no_apps_hint": "Pridajte vlastné aplikácie a odkazy do postranného panela", + "managed_title": "Poskytnuté správcom", + "managed_description": "Tieto aplikácie sú nastavené pre všetkých a nedajú sa zmeniť.", + "managed_badge": "Spravované", "search_icons": "Hľadať ikony...", "show_popular": "Obľúbené", "show_all": "Všetky", @@ -1867,6 +1870,8 @@ "description": "Spravujte vlastné aplikácie a odkazy v postrannom paneli", "keep_loaded": "Ponechať aplikácie načítané", "keep_loaded_description": "Udržiavať vložené aplikácie spustené na pozadí počas prepínania", + "managed_title": "Poskytnuté správcom", + "managed_description": "Tieto aplikácie sú nastavené pre všetkých a nedajú sa zmeniť.", "manage_title": "Vlastné aplikácie", "manage_description": "Pridávať, upravovať alebo odstraňovať vlastné aplikácie z postranného panela" }, diff --git a/locales/tr/common.json b/locales/tr/common.json index 1e1cd0bf4..89fea83ad 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -202,6 +202,9 @@ "delete_confirm": "\"{name}\" uygulamasını silmek istediğinizden emin misiniz?", "no_apps": "Henüz uygulama eklenmedi", "no_apps_hint": "Kenar çubuğunuza özel uygulamalar ve bağlantılar ekleyin", + "managed_title": "Yöneticiniz tarafından sağlandı", + "managed_description": "Bu uygulamalar herkes için ayarlanmıştır ve değiştirilemez.", + "managed_badge": "Yönetilen", "search_icons": "Simge ara...", "show_popular": "Popüler", "show_all": "Tümü", @@ -1860,6 +1863,8 @@ "description": "Kenar çubuğunuzdaki özel uygulamaları ve bağlantıları yönetin", "keep_loaded": "Uygulamaları Yüklü Tut", "keep_loaded_description": "Yeniden yüklemeyi önlemek için aralarında geçiş yaparken satır içi uygulamaları arka planda çalışır halde tutun", + "managed_title": "Yöneticiniz tarafından sağlandı", + "managed_description": "Bu uygulamalar herkes için ayarlanmıştır ve değiştirilemez.", "manage_title": "Özel Uygulamalar", "manage_description": "Kenar çubuğunuzdan özel uygulamalar ekleyin, düzenleyin veya kaldırın" }, diff --git a/locales/uk/common.json b/locales/uk/common.json index c62ee7469..2c4556053 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -202,6 +202,9 @@ "delete_confirm": "Ви впевнені, що хочете видалити \"{name}\"?", "no_apps": "Ще не додано жодної програми", "no_apps_hint": "Додайте власні програми та посилання на бічну панель", + "managed_title": "Надано вашим адміністратором", + "managed_description": "Ці застосунки налаштовано для всіх, і їх не можна змінити.", + "managed_badge": "Кероване", "search_icons": "Значки пошуку...", "show_popular": "Популярний", "show_all": "все", @@ -1860,6 +1863,8 @@ "description": "Керуйте спеціальними програмами та посиланнями на бічній панелі", "keep_loaded": "Тримайте програми завантаженими", "keep_loaded_description": "Під час перемикання між вбудованими програмами продовжуйте працювати у фоновому режимі, щоб уникнути перезавантаження", + "managed_title": "Надано вашим адміністратором", + "managed_description": "Ці застосунки налаштовано для всіх, і їх не можна змінити.", "manage_title": "Спеціальні програми", "manage_description": "Додавайте, редагуйте або видаляйте власні програми з бічної панелі" }, diff --git a/locales/zh/common.json b/locales/zh/common.json index eb0b206d6..216bd3ecf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -202,6 +202,9 @@ "delete_confirm": "您确定要删除\"{name}\"吗?", "no_apps": "尚未添加应用", "no_apps_hint": "将自定义应用和链接添加到侧边栏", + "managed_title": "由管理员提供", + "managed_description": "这些应用已为所有人配置,无法更改。", + "managed_badge": "受管理", "search_icons": "搜索图标...", "show_popular": "热门", "show_all": "全部", @@ -1860,6 +1863,8 @@ "description": "管理侧边栏中的自定义应用和链接", "keep_loaded": "保持应用加载", "keep_loaded_description": "在内联应用之间切换时,保持内联应用在后台运行以避免重新加载", + "managed_title": "由管理员提供", + "managed_description": "这些应用已为所有人配置,无法更改。", "manage_title": "定制应用", "manage_description": "从侧边栏添加、编辑或删除自定义应用" }, diff --git a/proxy.ts b/proxy.ts index 3dc1eb754..c260a5d06 100644 --- a/proxy.ts +++ b/proxy.ts @@ -4,6 +4,7 @@ import { routing } from "./i18n/routing"; import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins"; import { APP_FRAME_ORIGINS_COOKIE, + inlineAppFrameOrigins, parseAppFrameOrigins, } from "./lib/security/app-frame-origins"; import { configManager } from "./lib/admin/config-manager"; @@ -159,15 +160,21 @@ export async function proxy(request: NextRequest) { // read, so the client mirrors their origins into a cookie (#787). Ignored // when the admin has turned the feature off, so a stale cookie can't keep // widening the CSP after the fact. - const sidebarAppsEnabled = - configManager.getPolicy().features?.sidebarAppsEnabled !== false; + const policy = configManager.getPolicy(); + const sidebarAppsEnabled = policy.features?.sidebarAppsEnabled !== false; const appFrameOrigins = sidebarAppsEnabled ? parseAppFrameOrigins(request.cookies.get(APP_FRAME_ORIGINS_COOKIE)?.value) : []; + // Apps the operator pins for everyone (#931) are known server-side, so their + // origins go straight into the header - no cookie handshake, and no reload + // the first time a user opens one. They survive the gate above, which only + // governs the apps users add themselves. + const managedAppFrameOrigins = inlineAppFrameOrigins(policy.defaultSidebarApps); + const frameOrigins: string[] = []; const seenFrameOrigins = new Set(); - for (const origin of [...pluginFrameOrigins, ...appFrameOrigins]) { + for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins]) { const key = origin.toLowerCase(); if (seenFrameOrigins.has(key)) continue; seenFrameOrigins.add(key);