configurable default homepage
This commit is contained in:
10 files changed
+440
-8
No files matched your search
@@ -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'
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user