configurable default homepage

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 1cec7d0814
commit 86ef3f743b
10 files changed
+440 -8

No files matched your search

+39
View File
@@ -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;
}
+62
View File
@@ -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'
};
}