feat: default sidebar apps for all users #931

This commit is contained in:
Linus Rath
2026-08-29 12:31:17 +02:00
parent d800689924
commit 4fe5701b3b
39 changed files with 821 additions and 14 deletions
+231 -2
View File
@@ -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<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
@@ -127,6 +140,60 @@ export function PolicyTab() {
setMessage(null);
}
function updateDefaultApp(index: number, patch: Partial<AdminSidebarApp>) {
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 (
<div className="space-y-6">
@@ -351,6 +419,167 @@ export function PolicyTab() {
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Default Sidebar Apps</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Apps every user gets in the navigation rail without configuring anything. They appear
above the user&apos;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.
</p>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">Apps</span>
<p className="text-xs text-muted-foreground mt-0.5">
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.
</p>
</div>
<button
onClick={addDefaultApp}
disabled={defaultSidebarApps.length >= MAX_DEFAULT_SIDEBAR_APPS}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-50 disabled:pointer-events-none"
>
<Plus className="w-3.5 h-3.5" />
Add app
</button>
</div>
{defaultSidebarApps.length === 0 && (
<p className="text-xs text-muted-foreground py-2">
No default apps. Users only see the apps they add themselves.
</p>
)}
{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 (
<div key={app.id} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Name</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={50}
value={app.name}
onChange={(e) => 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"
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">URL</label>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
maxLength={2048}
value={app.url}
onChange={(e) => 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 && (
<p className="text-[11px] text-destructive mt-1">Enter a valid http or https URL</p>
)}
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Icon (
<a href="https://lucide.dev/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Lucide</a>
)
</label>
<div className="flex items-center gap-2">
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={64}
value={app.icon}
onChange={(e) => 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"
/>
<span
className="shrink-0 w-8 h-8 rounded-md border border-border bg-background flex items-center justify-center text-muted-foreground"
title={AppIcon ? app.icon : 'Unknown icon - falls back to Globe'}
>
{AppIcon ? <AppIcon className="w-4 h-4" /> : <span className="text-[10px]">?</span>}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode !== 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'tab' })}
className="border-input"
/>
New tab
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode === 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'inline' })}
className="border-input"
/>
Inline
</label>
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={app.showOnMobile}
onChange={() => updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
className="rounded border-input"
/>
Show on mobile
</label>
<div className="flex items-center gap-1 ms-auto">
<button
onClick={() => moveDefaultApp(index, -1)}
disabled={index === 0}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
title="Move up"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => moveDefaultApp(index, 1)}
disabled={index === defaultSidebarApps.length - 1}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
title="Move down"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeDefaultApp(index)}
className="text-muted-foreground hover:text-destructive p-1"
title="Remove app"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
+8 -1
View File
@@ -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) {
+4 -2
View File
@@ -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);
+50 -1
View File
@@ -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 (
<div className="mb-6">
<h3 className="text-sm font-semibold mb-1">{t('managed_title')}</h3>
<p className="text-xs text-muted-foreground mb-3">{t('managed_description')}</p>
<div className="space-y-3">
{managedApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
return (
<div key={app.id} className="flex items-center gap-3 p-3 border border-border rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-9 h-9 rounded-md bg-muted">
{AppIcon ? <AppIcon className="w-5 h-5 text-muted-foreground" /> : null}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{app.name}</p>
<p className="text-xs text-muted-foreground truncate">{app.url}</p>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-muted text-muted-foreground inline-flex items-center gap-1">
<Lock className="w-2.5 h-2.5" />
{t('managed_badge')}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full font-medium',
app.openMode === 'inline'
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
)}>
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
</span>
</div>
);
})}
</div>
</div>
);
}
interface SidebarAppsModalProps {
isOpen: boolean;
onClose: () => void;
@@ -258,6 +305,8 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<ManagedAppsList />
{/* Create form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
+3 -1
View File
@@ -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<string, unknown>;
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
+50 -1
View File
@@ -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 (
<SettingsSection title={t("managed_title")} description={t("managed_description")}>
<div className="space-y-3">
{managedApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
return (
<div key={app.id} className="flex items-center gap-3 p-3 border border-border rounded-lg bg-muted/30">
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
{AppIcon ? <AppIcon className="w-4 h-4" /> : null}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{app.name}</div>
<div className="text-xs text-muted-foreground truncate">{app.url}</div>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 bg-muted text-muted-foreground inline-flex items-center gap-1">
<Lock className="w-2.5 h-2.5" />
{tApps("managed_badge")}
</span>
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0",
app.openMode === "inline"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
)}>
{app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")}
</span>
</div>
);
})}
</div>
</SettingsSection>
);
}
export function SidebarAppsSettings() {
const t = useTranslations("settings.sidebar_apps");
const tApps = useTranslations("sidebar_apps");
@@ -251,6 +298,8 @@ export function SidebarAppsSettings() {
</SettingItem>
</SettingsSection>
<ManagedAppsSection />
<SettingsSection title={t("manage_title")} description={t("manage_description")}>
<div className="space-y-3">
{sidebarApps.length === 0 && !showAddForm && (
+29
View File
@@ -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]
);
}
+10 -3
View File
@@ -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<InlineAppState[]>([]);
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<string[] | null>(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) {
+155
View File
@@ -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<string, unknown> = {}) => ({
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,<h1>x</h1>' }),
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']);
});
});
+4
View File
@@ -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;
}
+25
View File
@@ -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<string, SettingRestriction>;
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 {
+112
View File
@@ -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<string>();
const out: AdminSidebarApp[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const app = entry as Partial<AdminSidebarApp>;
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];
}
+5
View File
@@ -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": "إضافة أو تعديل أو إزالة التطبيقات المخصصة من الشريط الجانبي"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "افزودن، ویرایش یا حذف برنامه‌های سفارشی از نوار کناری"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "הוסף, ערוך או הסר אפליקציות מותאמות אישית מהסרגל הצדדי שלך"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "サイドバーのカスタムアプリを追加、編集、削除"
},
+5
View File
@@ -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": "사이드바에 나만의 앱을 추가, 수정, 삭제해 보세요"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Хажуугийн цэсэндээ тусгай програм нэмэх, засах, устгах боломжтой"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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ă"
},
+5
View File
@@ -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": "Добавляйте, редактируйте и удаляйте пользовательские приложения из боковой панели"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Додавайте, редагуйте або видаляйте власні програми з бічної панелі"
},
+5
View File
@@ -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": "从侧边栏添加、编辑或删除自定义应用"
},
+10 -3
View File
@@ -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<string>();
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);