configurable default homepage
This commit is contained in:
10 files changed
+440
-8
No files matched your search
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
// Navigation preferences (default env + start page + env-click target). Saved to
|
||||
// /api/settings/navigation under the given scope ('global' from Settings, 'user' from Profile).
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||
import { LayoutDashboard, Box, ScrollText, Layers, Images, HardDrive, Network,
|
||||
Terminal, LibraryBig, Download, Activity, Archive, Timer, ClipboardList } from 'lucide-svelte';
|
||||
|
||||
let { scope = 'global' as 'global' | 'user' } = $props();
|
||||
const isUser = $derived(scope === 'user');
|
||||
|
||||
// Every landing/env-click page, MIRRORING the sidebar (same routes, icons, permission /
|
||||
// gate / enterprise conditions) so the dropdown only offers pages the user can actually
|
||||
// reach. `needsEnv` = the page is env-scoped (shown with the chosen env in the hint and
|
||||
// eligible for env-click). `permission` / `gate` / `enterpriseOnly` mirror canSeeMenuItem.
|
||||
const ALL_PAGES = [
|
||||
{ value: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, needsEnv: false, permission: 'always' },
|
||||
{ value: 'containers', label: 'Containers', Icon: Box, needsEnv: true, permission: 'containers' },
|
||||
{ value: 'logs', label: 'Logs', Icon: ScrollText, needsEnv: true, permission: 'containers' },
|
||||
{ value: 'terminal', label: 'Shell', Icon: Terminal, needsEnv: true, permission: 'containers' },
|
||||
{ value: 'stacks', label: 'Compose stacks', Icon: Layers, needsEnv: true, permission: 'stacks' },
|
||||
{ value: 'images', label: 'Images', Icon: Images, needsEnv: true, permission: 'images' },
|
||||
{ value: 'volumes', label: 'Volumes', Icon: HardDrive, needsEnv: true, permission: 'volumes' },
|
||||
{ value: 'networks', label: 'Networks', Icon: Network, needsEnv: true, permission: 'networks' },
|
||||
{ value: 'templates', label: 'Templates', Icon: LibraryBig, needsEnv: false, permission: 'templates' },
|
||||
{ value: 'registry', label: 'Registry', Icon: Download, needsEnv: false, permission: 'registries' },
|
||||
{ value: 'activity', label: 'Activity', Icon: Activity, needsEnv: false, permission: 'activity' },
|
||||
{ value: 'backups', label: 'Backups', Icon: Archive, needsEnv: false, permission: 'backups', gate: 'backups' },
|
||||
{ value: 'schedules', label: 'Schedules', Icon: Timer, needsEnv: false, permission: 'schedules' },
|
||||
{ value: 'audit', label: 'Audit log', Icon: ClipboardList, needsEnv: false, permission: 'audit_logs', enterpriseOnly: true }
|
||||
] as const;
|
||||
|
||||
// Visibility mirrors app-sidebar's canSeeMenuItem: hide the Backups beta unless the gate is
|
||||
// on, enterprise-only pages without an enterprise license, and (enterprise + auth on, non-
|
||||
// admin) pages the user has no permission for. Free/auth-off shows everything non-enterprise.
|
||||
function canSee(item: (typeof ALL_PAGES)[number]): boolean {
|
||||
const p = item as { permission?: string; gate?: string; enterpriseOnly?: boolean };
|
||||
if (p.gate === 'backups' && !$page.data?.backupsEnabled) return false;
|
||||
if (p.enterpriseOnly && !$licenseStore.isEnterprise) return false;
|
||||
if (!$licenseStore.isEnterprise || !$authStore.authEnabled) return true;
|
||||
if ($authStore.user?.isAdmin) return true;
|
||||
if (!p.permission || p.permission === 'always') return true;
|
||||
return $hasAnyAccess(p.permission as any);
|
||||
}
|
||||
|
||||
const PAGES = $derived(ALL_PAGES.filter(canSee));
|
||||
// Env-click: only env-scoped pages, and never Dashboard (you already clicked an env).
|
||||
const CLICK_PAGES = $derived(PAGES.filter((p) => p.needsEnv));
|
||||
|
||||
// In the 'user' scope, a null field means "inherit the global default". We surface that as a
|
||||
// pickable sentinel option so the user can go back to inheriting.
|
||||
const INHERIT = '__inherit__';
|
||||
|
||||
// State. Values: a page string, or null (=inherit in user scope). In GLOBAL scope null is
|
||||
// never stored — an unset field defaults to 'dashboard' (landing) / 'containers' (env-click).
|
||||
let landingPage = $state<string | null>(null);
|
||||
let envClickPageV = $state<string | null>(null);
|
||||
let loaded = $state(false);
|
||||
// The global values, shown as the "(default: X)" hint in user scope.
|
||||
let globalDefaults = $state<{ landingPage: string | null; envClickPage: string | null }>({ landingPage: null, envClickPage: null });
|
||||
|
||||
const pageOf = (v: string | null) => PAGES.find((p) => p.value === v) ?? PAGES[0];
|
||||
|
||||
// Resolve a stored field to the value the Select should show. In user scope an unset
|
||||
// field (null) stays inherit; in global scope it falls back to the given default.
|
||||
const resolve = (v: string | null, globalDefault: string) => v ?? (isUser ? INHERIT : globalDefault);
|
||||
// Home defaults to Dashboard; env-click defaults to a concrete page (containers).
|
||||
const homeSel = $derived(resolve(landingPage, 'dashboard'));
|
||||
const clickSel = $derived(resolve(envClickPageV, 'containers'));
|
||||
|
||||
// Label helper for the trigger + the (default: X) hint.
|
||||
const pageLabel = (v: string | null): string => v ? pageOf(v).label : 'Dashboard';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/navigation');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
globalDefaults = { landingPage: data.global?.landingPage ?? null, envClickPage: data.global?.envClickPage ?? null };
|
||||
const p = isUser ? (data.user ?? {}) : (data.global ?? {});
|
||||
landingPage = p.landingPage ?? null;
|
||||
envClickPageV = p.envClickPage ?? null;
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
function pickHome(v: string) {
|
||||
landingPage = v === INHERIT ? null : v;
|
||||
persist();
|
||||
}
|
||||
function pickClick(v: string) {
|
||||
envClickPageV = v === INHERIT ? null : v;
|
||||
persist();
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
async function save() {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/navigation?scope=${scope}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
// '' clears (inherit in user scope). A page value sets it.
|
||||
landingPage: landingPage ?? '',
|
||||
envClickPage: envClickPageV ?? ''
|
||||
})
|
||||
});
|
||||
if (!res.ok) toast.error('Failed to save navigation preference');
|
||||
} catch {
|
||||
toast.error('Failed to save navigation preference');
|
||||
}
|
||||
}
|
||||
function persist() {
|
||||
if (!loaded) return;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(save, 250);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Two groups side by side: "Open the app on" (which page + which env) and "Environment
|
||||
click". Wraps to a column on narrow screens. -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-6">
|
||||
|
||||
<!-- 1. Open the app on: the landing PAGE. The environment is not forced - the app opens on
|
||||
the last-used one. -->
|
||||
<div class="space-y-1.5 min-w-0 flex-1">
|
||||
<Label>Open the app on</Label>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Select.Root type="single" value={homeSel} onValueChange={(v) => { if (v) pickHome(v); }}>
|
||||
<Select.Trigger class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isUser && landingPage === null}
|
||||
<span class="text-muted-foreground truncate">Global default ({pageLabel(globalDefaults.landingPage ?? 'dashboard')})</span>
|
||||
{:else}
|
||||
{@const m = pageOf(landingPage)}
|
||||
<m.Icon class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="truncate">{m.label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#if isUser}
|
||||
<Select.Item value={INHERIT}><span class="text-muted-foreground">Use global default</span></Select.Item>
|
||||
{/if}
|
||||
{#each PAGES as page}
|
||||
<Select.Item value={page.value}>
|
||||
<div class="flex items-center gap-2">
|
||||
<page.Icon class="w-4 h-4 text-muted-foreground" />
|
||||
<span>{page.label}</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Where the app opens (on the last-used environment).</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. Dashboard env-click target. Clicking an environment is an intentional "show me THIS
|
||||
view of this env", so it's always a concrete page (default: containers). -->
|
||||
<div class="space-y-1.5 min-w-0 flex-1">
|
||||
<Label>Environment click</Label>
|
||||
<Select.Root type="single" value={clickSel} onValueChange={(v) => { if (v) pickClick(v); }}>
|
||||
<Select.Trigger class="w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isUser && envClickPageV === null}
|
||||
<span class="text-muted-foreground">Global default ({pageLabel(globalDefaults.envClickPage ?? 'containers')})</span>
|
||||
{:else}
|
||||
{@const m = pageOf(envClickPageV ?? 'containers')}
|
||||
<m.Icon class="w-4 h-4 text-muted-foreground" />
|
||||
<span>{m.label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#if isUser}
|
||||
<Select.Item value={INHERIT}><span class="text-muted-foreground">Use global default</span></Select.Item>
|
||||
{/if}
|
||||
{#each CLICK_PAGES as page}
|
||||
<Select.Item value={page.value}>
|
||||
<div class="flex items-center gap-2">
|
||||
<page.Icon class="w-4 h-4 text-muted-foreground" />
|
||||
<span>{page.label}</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">Where a tile click goes.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,8 +71,10 @@
|
||||
const sidebar = useSidebar();
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/') return currentPath === '/';
|
||||
return currentPath === path || currentPath.startsWith(`${path}/`);
|
||||
// The Dashboard link carries a ?home marker; compare on the path only.
|
||||
const p = path.split('?')[0];
|
||||
if (p === '/') return currentPath === '/';
|
||||
return currentPath === p || currentPath.startsWith(`${p}/`);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -121,7 +123,7 @@
|
||||
}
|
||||
|
||||
const menuItems: readonly MenuItem[] = [
|
||||
{ href: '/', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
|
||||
{ href: '/?home', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
|
||||
{ href: '/containers', Icon: Box, label: 'Containers', permission: 'containers' },
|
||||
{ href: '/logs', Icon: ScrollText, label: 'Logs', permission: 'containers' },
|
||||
{ href: '/terminal', Icon: Terminal, label: 'Shell', permission: 'containers' },
|
||||
@@ -241,7 +243,7 @@
|
||||
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
|
||||
<!-- Expanded state: logo + collapse button -->
|
||||
<div class="relative flex items-center justify-center w-full group-data-[state=collapsed]:hidden">
|
||||
<a href="/" class="flex justify-center relative">
|
||||
<a href="/?home" class="flex justify-center relative">
|
||||
<img src="/logo-light.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 dark:hidden" style="filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.9));" />
|
||||
<img src="/logo-dark.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 hidden dark:block" style="filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.2));" />
|
||||
{#if $licenseStore.isEnterprise}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* nav-preferences-core.ts — the PURE parts of the navigation preferences: the page allowlist
|
||||
* and the incoming-patch validation. No DB import, so it is unit-testable directly (importing
|
||||
* nav-preferences.ts pulls in db/drizzle -> better-sqlite3, which bun's test runner can't load).
|
||||
* nav-preferences.ts re-exports these so callers have one import site.
|
||||
*/
|
||||
|
||||
export interface NavPreferences {
|
||||
landingPage: string | null; // 'dashboard' | 'containers' | ... | null (=inherit/unset)
|
||||
envClickPage: string | null; // where a Dashboard env-tile click goes
|
||||
}
|
||||
|
||||
// The single list of pages the landing/env-click dropdowns can pick — the sidebar routes.
|
||||
// ONE source of truth: the API validation allowlist and the NavigationSelector UI both use
|
||||
// this, so a new page can't drift between "offered in the UI" and "accepted on save".
|
||||
export const PAGE_SLUGS = [
|
||||
'dashboard', 'containers', 'logs', 'terminal', 'stacks', 'images', 'volumes', 'networks',
|
||||
'templates', 'registry', 'activity', 'backups', 'schedules', 'audit'
|
||||
] as const;
|
||||
|
||||
/** Coerce + validate an incoming nav-prefs patch (pure). A field set to '' / null clears it
|
||||
* (inherit/none). Throws on an invalid page/env so the API returns 400. */
|
||||
export function parseNavPatch(data: any): Partial<NavPreferences> {
|
||||
const out: Partial<NavPreferences> = {};
|
||||
if (data.landingPage !== undefined) {
|
||||
const p = data.landingPage;
|
||||
if (p === '' || p === null) out.landingPage = null;
|
||||
else if ((PAGE_SLUGS as readonly string[]).includes(p)) out.landingPage = p;
|
||||
else throw new Error('Invalid landingPage');
|
||||
}
|
||||
if (data.envClickPage !== undefined) {
|
||||
const p = data.envClickPage;
|
||||
// env-click is always a concrete page, never dashboard (you already clicked an env).
|
||||
if (p === '' || p === null) out.envClickPage = null;
|
||||
else if ((PAGE_SLUGS as readonly string[]).includes(p) && p !== 'dashboard') out.envClickPage = p;
|
||||
else throw new Error('Invalid envClickPage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Navigation preferences (landing page + env-click target), stored as plain settings keys.
|
||||
// GLOBAL = bare key; PER-USER = user:<id>:<key>. A per-user value of '' / null means "use the
|
||||
// global default". Kept out of the (already huge) db.ts - it only leans on the generic settings
|
||||
// get/set helpers.
|
||||
import { getSetting, setSetting, setUserSetting } from './db.js';
|
||||
// The type + patch validation live in a db-free core module so they stay unit-testable
|
||||
// (importing this file pulls in db/drizzle -> better-sqlite3). Re-exported so the API route
|
||||
// gets the type, parseNavPatch, and the db-backed getters/setters from one import site.
|
||||
import { type NavPreferences, parseNavPatch } from './nav-preferences-core.js';
|
||||
export { type NavPreferences, parseNavPatch };
|
||||
|
||||
async function readNav(scopeKey: (k: string) => string): Promise<NavPreferences> {
|
||||
const [page, click] = await Promise.all([
|
||||
getSetting(scopeKey('landing_page')),
|
||||
getSetting(scopeKey('env_click_page'))
|
||||
]);
|
||||
const s = (v: any) => (typeof v === 'string' && v.trim() !== '' ? v : null);
|
||||
return {
|
||||
landingPage: s(page),
|
||||
envClickPage: s(click)
|
||||
};
|
||||
}
|
||||
|
||||
/** Global navigation defaults (whole instance). */
|
||||
export async function getGlobalNavPreferences(): Promise<NavPreferences> {
|
||||
return readNav((k) => k);
|
||||
}
|
||||
export async function setGlobalNavPreferences(prefs: Partial<NavPreferences>): Promise<void> {
|
||||
const ups: Promise<void>[] = [];
|
||||
if (prefs.landingPage !== undefined) ups.push(setSetting('landing_page', prefs.landingPage ?? ''));
|
||||
if (prefs.envClickPage !== undefined) ups.push(setSetting('env_click_page', prefs.envClickPage ?? ''));
|
||||
await Promise.all(ups);
|
||||
}
|
||||
|
||||
/** Per-user overrides. A null field inherits the global default. */
|
||||
export async function getUserNavPreferences(userId: number): Promise<NavPreferences> {
|
||||
return readNav((k) => `user:${userId}:${k}`);
|
||||
}
|
||||
export async function setUserNavPreferences(userId: number, prefs: Partial<NavPreferences>): Promise<void> {
|
||||
const ups: Promise<void>[] = [];
|
||||
if (prefs.landingPage !== undefined) ups.push(setUserSetting(userId, 'landing_page', prefs.landingPage ?? ''));
|
||||
if (prefs.envClickPage !== undefined) ups.push(setUserSetting(userId, 'env_click_page', prefs.envClickPage ?? ''));
|
||||
await Promise.all(ups);
|
||||
}
|
||||
|
||||
/** Effective navigation prefs for a request: per-user value wins, else global, else the default.
|
||||
* An unset landing page resolves to 'dashboard'. `userId` null (auth off / no user) uses global
|
||||
* only. NOTE: consumed by the client landing redirect + dashboard env-click. The NavigationSelector
|
||||
* UI reads the raw `global`/`user` views (where null still means inherit/unset), never this. */
|
||||
export async function getEffectiveNavPreferences(userId: number | null): Promise<NavPreferences> {
|
||||
const global = await getGlobalNavPreferences();
|
||||
const user = userId == null ? null : await getUserNavPreferences(userId);
|
||||
const merged = user == null ? global : {
|
||||
landingPage: user.landingPage ?? global.landingPage,
|
||||
envClickPage: user.envClickPage ?? global.envClickPage
|
||||
};
|
||||
return {
|
||||
landingPage: merged.landingPage ?? 'dashboard',
|
||||
// Env-click is always a concrete view (default: containers).
|
||||
envClickPage: merged.envClickPage ?? 'containers'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Custom-homepage landing decision (pure). SSR is off, so `/`'s client-side load (+page.ts)
|
||||
// decides whether opening the app should redirect to the user's landing page. Kept pure so the
|
||||
// redirect rule lives in one testable place.
|
||||
|
||||
export interface NavEffective {
|
||||
landingPage?: string | null;
|
||||
envClickPage?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the user when opening `/`, or null to stay on the dashboard. Redirects
|
||||
* whenever the landing page is a real page other than the dashboard, UNLESS the URL carries the
|
||||
* `?home` marker (the sidebar's Dashboard link sets it, so clicking Dashboard is not bounced
|
||||
* away). The environment is not forced here - it stays whatever the store already holds.
|
||||
*/
|
||||
export function landingRedirectTarget(nav: NavEffective | null | undefined, hasHomeMarker: boolean): string | null {
|
||||
if (hasHomeMarker) return null;
|
||||
const page = nav?.landingPage;
|
||||
if (!page || page === 'dashboard') return null;
|
||||
return `/${page}`;
|
||||
}
|
||||
+15
-2
@@ -61,6 +61,9 @@
|
||||
let locked = $state(false);
|
||||
let viewMode = $state<'grid' | 'list'>('grid');
|
||||
|
||||
// Where a tile click navigates (nav preference; default containers). Loaded in onMount.
|
||||
let envClickPage = $state('containers');
|
||||
|
||||
// List view filter state
|
||||
let listSearchQuery = $state('');
|
||||
let listConnectionFilter = $state<string[]>([]);
|
||||
@@ -658,12 +661,12 @@
|
||||
return tiles.find(t => t.id === id);
|
||||
}
|
||||
|
||||
// Handle tile click - select environment and navigate to containers
|
||||
// Handle tile click - select environment and navigate to the env-click target page
|
||||
function handleTileClick(envId: number) {
|
||||
const tile = getTileById(envId);
|
||||
if (tile?.stats) {
|
||||
currentEnvironment.set({ id: envId, name: tile.stats.name });
|
||||
goto('/containers');
|
||||
goto(`/${envClickPage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +942,16 @@
|
||||
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
|
||||
document.addEventListener('resume', handleVisibilityChange);
|
||||
|
||||
// Env-click target for the tiles. The landing redirect itself lives in +page.ts (runs before
|
||||
// this component mounts), so reaching here means we're staying on the dashboard.
|
||||
try {
|
||||
const res = await fetch('/api/settings/navigation');
|
||||
if (res.ok) {
|
||||
const nav = (await res.json())?.effective;
|
||||
if (nav?.envClickPage) envClickPage = nav.envClickPage;
|
||||
}
|
||||
} catch { /* default env-click */ }
|
||||
|
||||
// Load label filter and custom label colors
|
||||
loadLabelFilter();
|
||||
labelColorOverrides.load();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { landingRedirectTarget } from '$lib/utils/landing';
|
||||
|
||||
// SSR is off, so this load runs on the client before the dashboard mounts. Opening `/` redirects
|
||||
// to the configured landing page here - no dashboard flash, no placeholder. The sidebar's Dashboard
|
||||
// link carries `?home` to suppress the redirect (see landingRedirectTarget).
|
||||
export const load = async ({ url, fetch }) => {
|
||||
if (url.searchParams.has('home')) return {};
|
||||
try {
|
||||
const res = await fetch('/api/settings/navigation');
|
||||
if (!res.ok) return {};
|
||||
const nav = (await res.json())?.effective;
|
||||
const target = landingRedirectTarget(nav, false);
|
||||
if (target) throw redirect(307, target);
|
||||
} catch (e) {
|
||||
// Re-throw the redirect; swallow only a failed nav fetch (stay on the dashboard).
|
||||
if (e && typeof e === 'object' && 'status' in e && 'location' in e) throw e;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import {
|
||||
getGlobalNavPreferences, setGlobalNavPreferences,
|
||||
getUserNavPreferences, setUserNavPreferences,
|
||||
getEffectiveNavPreferences,
|
||||
parseNavPatch,
|
||||
type NavPreferences
|
||||
} from '$lib/server/nav-preferences';
|
||||
import { validateSession, isAuthEnabled } from '$lib/server/auth';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
// GET /api/settings/navigation — returns effective prefs (for redirect/env-click), plus the raw
|
||||
// global + per-user values so the UI can show what's inherited. Auth off => global only.
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
const user = authEnabled ? await validateSession(cookies) : null;
|
||||
const global = await getGlobalNavPreferences();
|
||||
const userPrefs = user ? await getUserNavPreferences(user.id) : null;
|
||||
const effective = await getEffectiveNavPreferences(user?.id ?? null);
|
||||
return json({ effective, global, user: userPrefs });
|
||||
};
|
||||
|
||||
// PUT /api/settings/navigation?scope=global|user
|
||||
export const PUT: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const scope = url.searchParams.get('scope') ?? 'global';
|
||||
let patch: Partial<NavPreferences>;
|
||||
try {
|
||||
patch = parseNavPatch(await request.json());
|
||||
} catch (e) {
|
||||
return json({ error: e instanceof Error ? e.message : 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (scope === 'user') {
|
||||
if (!(await isAuthEnabled())) return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
const user = await validateSession(cookies);
|
||||
if (!user) return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
await setUserNavPreferences(user.id, patch);
|
||||
return json({ success: true, user: await getUserNavPreferences(user.id) });
|
||||
}
|
||||
|
||||
// global scope — needs settings-edit permission (mirrors /api/settings/general POST)
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('settings', 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
await setGlobalNavPreferences(patch);
|
||||
return json({ success: true, global: await getGlobalNavPreferences() });
|
||||
};
|
||||
@@ -24,7 +24,8 @@
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Palette,
|
||||
Plus
|
||||
Plus,
|
||||
Compass
|
||||
} from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
@@ -38,6 +39,7 @@
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
|
||||
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
|
||||
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
@@ -732,6 +734,20 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Navigation card (per-user overrides) -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Compass class="w-5 h-5" />
|
||||
Navigation
|
||||
</Card.Title>
|
||||
<Card.Description>Your landing page and environment-click target. Leave on "Use global default" to follow the workspace setting.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<NavigationSelector scope="user" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown } from 'lucide-svelte';
|
||||
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown, Compass } from 'lucide-svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
|
||||
import { canAccess, authStore } from '$lib/stores/auth';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
|
||||
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
|
||||
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -523,6 +524,18 @@ services:
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium flex items-center gap-2">
|
||||
<Compass class="w-4 h-4" />
|
||||
Navigation
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<NavigationSelector />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user