From 0aaa15240d674eaad04c3fabaf72d1e59c1d80ed Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 28 Sep 2026 22:13:49 +0200 Subject: [PATCH] feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank - Spalte User.dashboardBackground (JSONB) samt Migration - PATCH /users/me/dashboard-background, geprueft mit parseDashboardBackground aus @tessera/shared (Allowlist, UUID-Bildkennung) - getMe liefert dashboardBackground normalisiert neben accentColor - Web liest die Wahl aus dem Auth-Store, speichert ueber die Server-Aktion, alte localStorage-Wahl wird einmalig uebernommen - Hinweistext: gilt auf jedem Geraet Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 16 +++ apps/api/prisma/schema.prisma | 4 + apps/api/src/auth/auth.service.ts | 7 +- apps/api/src/user/user.controller.ts | 43 +++++++ apps/web/src/app/(portal)/page.tsx | 7 +- .../dashboard/dashboard-background.test.tsx | 24 +++- .../dashboard/dashboard-background.tsx | 117 +++++++++++++++--- apps/web/src/components/layout/header.tsx | 1 + apps/web/src/lib/auth-actions.ts | 27 ++++ apps/web/src/lib/dashboard-background.test.ts | 4 +- apps/web/src/lib/dashboard-background.ts | 78 ++++++------ apps/web/src/lib/stores/auth-store.ts | 3 + apps/web/src/messages/de.json | 2 +- apps/web/src/messages/en.json | 2 +- ...andantentrennung-zugriffsklassifikation.md | 2 +- packages/shared/src/index.ts | 62 ++++++++++ 16 files changed, 333 insertions(+), 66 deletions(-) create mode 100644 apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql diff --git a/apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql b/apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql new file mode 100644 index 0000000..e01fcd0 --- /dev/null +++ b/apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql @@ -0,0 +1,16 @@ +-- quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank. +-- +-- Bisher lag die Wahl des Dashboard-Hintergrunds (Design "Mosaik") im +-- localStorage des Browsers und folgte dem Benutzer nicht auf ein anderes +-- Geraet oder in die Desktop-App. Jetzt steht sie hier, geschrieben nur ueber +-- PATCH /users/me/dashboard-background und dort wie beim Lesen durch +-- parseDashboardBackground (@tessera/shared) geprueft und normalisiert. +-- NULL = nie gewaehlt (das Web uebernimmt dann einmalig eine alte +-- localStorage-Wahl); sonst ein Objekt { kind: 'none' | 'preset' | 'image', ... }. +-- Bewusst kein Standardwert und kein Backfill. +-- +-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste +-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt. + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1443c90..bc41987 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -46,6 +46,10 @@ model User { // quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das // "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version) lastSeenReleaseVersion String? + // quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt, + // sonst das durch parseDashboardBackground (@tessera/shared) normalisierte + // Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund" + dashboardBackground Json? passwordResetTokens PasswordResetToken[] groupMemberships GroupMembership[] moduleGrants ModuleGrant[] diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 07e98c4..7822ef5 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -8,6 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Role } from '@prisma/client'; +import { parseDashboardBackground } from '@tessera/shared'; import * as argon2 from 'argon2'; import { randomUUID } from 'node:crypto'; import { Response } from 'express'; @@ -331,6 +332,7 @@ export class AuthService { ldapDn: true, avatarPath: true, accentColor: true, + dashboardBackground: true, }, }); @@ -338,10 +340,13 @@ export class AuthService { return null; } - const { passwordHash, ldapDn, avatarPath, ...publicFields } = user; + const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user; return { ...publicFields, + // quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame + // Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null. + dashboardBackground: parseDashboardBackground(dashboardBackground), isLocalUser: !!passwordHash && !ldapDn, hasAvatar: !!avatarPath, }; diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 693b43c..54720c0 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -20,6 +20,8 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; import { compareReleaseVersions, + type DashboardBackground, + parseDashboardBackground, parseReleaseVersion, type ReleaseNoticeResponse, } from '@tessera/shared'; @@ -62,6 +64,10 @@ function resolveAvatarsDir(): string { * Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen` * ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit * `where: { id: currentUser.id }`. + * + * quick-260928-ujj: dazu kommt ein gebundener Zugriff des + * Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls + * `forTenant()` mit `where: { id: currentUser.id }`. */ @Controller('users') @UseGuards(RolesGuard) @@ -483,6 +489,43 @@ export class UserController { return { success: true }; } + /** + * PATCH /users/me/dashboard-background (quick-260928-ujj) + * + * Speichert den gewaehlten Dashboard-Hintergrund des angemeldeten + * Benutzers. Jeder angemeldete Benutzer, kein `@Roles`. + * + * T-ujj-01 (Tampering): der Wert wird spaeter als CSS-Hintergrund + * gerendert. `parseDashboardBackground` (@tessera/shared) laesst nur + * `kind` none/preset/image, bekannte Preset-Kennungen und eine UUID als + * Bildkennung zu und baut ein frisches Objekt ohne Zusatzschluessel; + * alles andere ergibt 400 ohne Schreibzugriff. Bewusst Inline-Body-Typ + * statt DTO-Klasse (wie `me/accent-color`): die globale ValidationPipe mit + * `whitelist` wuerde das verschachtelte Objekt sonst nicht pruefen. + * + * T-ujj-02 (Elevation of Privilege): kein Kennungsparameter; geschrieben + * wird ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) + * ueber `forTenant(this.prisma, currentUser.tenantId)`. + */ + @Patch('me/dashboard-background') + async updateDashboardBackground( + @Body() body: { background: unknown }, + @CurrentUser() currentUser: AuthUser, + ): Promise<{ success: true; dashboardBackground: DashboardBackground }> { + const background = parseDashboardBackground(body?.background); + if (background === null) { + throw new BadRequestException('Invalid dashboard background.'); + } + + const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); + await tenantPrisma.user.update({ + where: { id: currentUser.id }, + data: { dashboardBackground: background }, + }); + + return { success: true, dashboardBackground: background }; + } + /** * GET /users/me/avatar * Stream the current user's avatar image. diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 721421b..3193912 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -6,7 +6,6 @@ import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background'; -import { useAuthStore } from '@/lib/stores/auth-store'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { registerWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; @@ -50,9 +49,9 @@ export default function DashboardPage() { // Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung // und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht. const [gridInset, setGridInset] = useState(0); - // Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage). - const userId = useAuthStore((s) => s.user?.id); - const { background, choose: chooseBackground } = useDashboardBackground(userId); + // Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der + // Datenbank gespeichert und mit der Sitzung geladen. + const { background, choose: chooseBackground } = useDashboardBackground(); const hasBackground = background.kind !== 'none'; // quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf — // der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst diff --git a/apps/web/src/components/dashboard/dashboard-background.test.tsx b/apps/web/src/components/dashboard/dashboard-background.test.tsx index 6bf2943..55733ba 100644 --- a/apps/web/src/components/dashboard/dashboard-background.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-background.test.tsx @@ -52,7 +52,9 @@ describe('useDashboardBackground (quick-260928-ujj)', () => { }); it('liest die Wahl aus dem Auth-Store; null ergibt „kein Hintergrund“', async () => { - useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }) }); + useAuthStore.setState({ + user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }), + }); const { result } = await renderBackgroundHook(); expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }); @@ -68,17 +70,26 @@ describe('useDashboardBackground (quick-260928-ujj)', () => { result.current.choose({ kind: 'preset', id: 'mosaic' }); }); - expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'mosaic' }); + expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ + kind: 'preset', + id: 'mosaic', + }); expect(result.current.background).toEqual({ kind: 'preset', id: 'mosaic' }); expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' }); }); it.each([ - ['Fehlerantwort', () => updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' })], + [ + 'Fehlerantwort', + () => + updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' }), + ], ['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))], ])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => { arrange(); - useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }) }); + useAuthStore.setState({ + user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }), + }); const { result } = await renderBackgroundHook(); act(() => { @@ -86,7 +97,10 @@ describe('useDashboardBackground (quick-260928-ujj)', () => { }); await waitFor(() => expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' })); - expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'dunes' }); + expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ + kind: 'preset', + id: 'dunes', + }); }); it('Server-Wert null und alte localStorage-Wahl: wird genau einmal gespeichert, Schluessel entfernt', async () => { diff --git a/apps/web/src/components/dashboard/dashboard-background.tsx b/apps/web/src/components/dashboard/dashboard-background.tsx index 9801f94..072d3c6 100644 --- a/apps/web/src/components/dashboard/dashboard-background.tsx +++ b/apps/web/src/components/dashboard/dashboard-background.tsx @@ -3,13 +3,13 @@ import { useTranslations } from 'next-intl'; import { useTheme } from 'next-themes'; import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; +import { updateDashboardBackgroundAction } from '@/lib/auth-actions'; import { BACKGROUND_PRESETS, type DashboardBackground, - loadDashboardBackground, NO_BACKGROUND, presetBackground, - saveDashboardBackground, + takeLegacyDashboardBackground, } from '@/lib/dashboard-background'; import { type DashboardImageMeta, @@ -17,23 +17,61 @@ import { fetchDashboardImages, uploadDashboardImage, } from '@/lib/dashboard-images-api'; +import { useAuthStore } from '@/lib/stores/auth-store'; -/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */ -export function useDashboardBackground(userId: string | null | undefined) { - const [background, setBackground] = useState(NO_BACKGROUND); +/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */ +function setStoredBackground(userId: string, value: DashboardBackground | null) { + const current = useAuthStore.getState().user; + if (!current || current.id !== userId) return; + useAuthStore.getState().setUser({ ...current, dashboardBackground: value }); +} - useEffect(() => { - if (userId) setBackground(loadDashboardBackground(userId)); - }, [userId]); +/** + * Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj). + * + * Gelesen aus dem Auth-Store (Sitzungsantwort, `null` = nie gewaehlt = + * kein Hintergrund). `choose()` setzt den Store sofort und speichert ueber + * die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck. + * Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor, + * wird sie genau einmal je Benutzerkennung uebernommen. + */ +export function useDashboardBackground() { + const user = useAuthStore((s) => s.user); + const userId = user?.id ?? null; + const stored = user?.dashboardBackground ?? null; + const background = stored ?? NO_BACKGROUND; + const migratedFor = useRef(null); + + const save = useCallback( + (id: string, value: DashboardBackground, previous: DashboardBackground | null) => { + setStoredBackground(id, value); + const revert = () => setStoredBackground(id, previous); + updateDashboardBackgroundAction(value) + .then((result) => { + if (!result.success) revert(); + }) + .catch(revert); + }, + [], + ); const choose = useCallback( (value: DashboardBackground) => { - setBackground(value); - if (userId) saveDashboardBackground(userId, value); + if (!userId) return; + save(userId, value, stored); }, - [userId], + [userId, stored, save], ); + useEffect(() => { + if (!userId || migratedFor.current === userId) return; + migratedFor.current = userId; + // Auch bei gesetztem Server-Wert auslesen: der alte Schluessel wird so + // aufgeraeumt, uebernommen wird aber nur, wenn noch nie gewaehlt wurde. + const legacy = takeLegacyDashboardBackground(userId); + if (legacy && stored === null) save(userId, legacy, null); + }, [userId, stored, save]); + return { background, choose }; } @@ -164,13 +202,17 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) { > {content} - + {label} @@ -187,7 +229,18 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) { aria-haspopup="dialog" className="btn btn-subtle" > -

{t('builtIn')}

    - {option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' }, - , + {option( + 'none', + t('none'), + NO_BACKGROUND, + { backgroundColor: 'var(--background)' }, + , )} {BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => { const css = (dark ? preset.dark : preset.light) ?? preset.light; @@ -247,7 +317,20 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) { {uploading ? ( ) : ( - + )} {t('upload')} diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index c99c761..0c5bf55 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -53,6 +53,7 @@ export function Header() { tenantId: u.tenantId, hasAvatar: u.hasAvatar, accentColor: u.accentColor, + dashboardBackground: u.dashboardBackground ?? null, }); } else if (state.status === 'unauthenticated') { if (redirectedRef.current) { diff --git a/apps/web/src/lib/auth-actions.ts b/apps/web/src/lib/auth-actions.ts index cc8c1ae..fd3935a 100644 --- a/apps/web/src/lib/auth-actions.ts +++ b/apps/web/src/lib/auth-actions.ts @@ -1,5 +1,6 @@ 'use server'; +import type { DashboardBackground } from '@tessera/shared'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -15,6 +16,8 @@ export interface AuthUser { isLocalUser?: boolean; hasAvatar?: boolean; accentColor?: string | null; + /** quick-260928-ujj: null = nie gewaehlt. */ + dashboardBackground?: DashboardBackground | null; } export interface LoginResult { @@ -236,6 +239,30 @@ export async function updateAccentColorAction( } } +/** + * Speichert den Dashboard-Hintergrund des angemeldeten Benutzers + * (quick-260928-ujj). Die API prueft die Wahl (parseDashboardBackground) + * und antwortet bei ungueltigem Wert mit 400. + */ +export async function updateDashboardBackgroundAction( + background: DashboardBackground, +): Promise<{ success: boolean; error?: string }> { + const cookieStore = await cookies(); + const session = cookieStore.get('session')?.value; + if (!session) return { success: false, error: 'notAuthenticated' }; + try { + const response = await fetch(`${API_URL}/users/me/dashboard-background`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Cookie: `session=${session}` }, + body: JSON.stringify({ background }), + }); + if (!response.ok) return { success: false, error: 'saveError' }; + return { success: true }; + } catch { + return { success: false, error: 'networkError' }; + } +} + export type SessionState = | { status: 'authenticated'; user: AuthUser } | { status: 'unauthenticated' } diff --git a/apps/web/src/lib/dashboard-background.test.ts b/apps/web/src/lib/dashboard-background.test.ts index 77b24a3..977e655 100644 --- a/apps/web/src/lib/dashboard-background.test.ts +++ b/apps/web/src/lib/dashboard-background.test.ts @@ -66,7 +66,9 @@ describe('dashboard-background (Design „Mosaik“)', () => { }); it('die eingebauten Hintergruende sind deckungsgleich mit der Pruefregel in @tessera/shared', () => { - expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual([...DASHBOARD_BACKGROUND_PRESET_IDS].sort()); + expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual( + [...DASHBOARD_BACKGROUND_PRESET_IDS].sort(), + ); }); it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => { diff --git a/apps/web/src/lib/dashboard-background.ts b/apps/web/src/lib/dashboard-background.ts index 955a373..8e7b550 100644 --- a/apps/web/src/lib/dashboard-background.ts +++ b/apps/web/src/lib/dashboard-background.ts @@ -1,20 +1,28 @@ /** - * Dashboard-Hintergrund (Prototyp, Design „Mosaik“). + * Dashboard-Hintergrund (Design „Mosaik“). * * Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und * drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen- * verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern - * (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im - * localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld - * kann folgen, wenn die Funktion bleibt. + * (`/dashboard/images`). + * + * Gespeichert wird seit quick-260928-ujj in der Datenbank + * (`User.dashboardBackground`, `PATCH /users/me/dashboard-background`) und + * mit der Sitzungsantwort gelesen — die Wahl folgt dem Benutzer auf jedes + * Geraet. Typ, Preset-Kennungen und Pruefregel kommen aus `@tessera/shared` + * (EINE Regel fuer API und Web). Der fruehere localStorage-Schluessel wird + * nur noch einmalig ausgelesen und entfernt ({@link takeLegacyDashboardBackground}). */ -export type BackgroundPresetId = 'mist' | 'pebble' | 'bloom' | 'dunes' | 'mosaic'; +import { + type DashboardBackground, + type DashboardBackgroundPresetId, + parseDashboardBackground, +} from '@tessera/shared'; -export type DashboardBackground = - | { kind: 'none' } - | { kind: 'preset'; id: BackgroundPresetId } - | { kind: 'image'; imageId: string }; +export type { DashboardBackground }; + +export type BackgroundPresetId = DashboardBackgroundPresetId; export const NO_BACKGROUND: DashboardBackground = { kind: 'none' }; @@ -22,7 +30,8 @@ function svgUrl(svg: string): string { return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; } -const VIEW = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"'; +const VIEW = + 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"'; function bloom(base: string, a: string, b: string, c: string): string { return svgUrl( @@ -121,35 +130,34 @@ export function presetBackground(id: BackgroundPresetId, dark: boolean): string return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null; } -function storageKey(userId: string): string { +/** Schluessel der frueheren localStorage-Speicherung (Prototyp, vor quick-260928-ujj). */ +function legacyStorageKey(userId: string): string { return `tessera.dashboardBackground.${userId}`; } -function isValid(value: unknown): value is DashboardBackground { - if (typeof value !== 'object' || value === null) return false; - const v = value as Record; - if (v.kind === 'none') return true; - if (v.kind === 'preset') return BACKGROUND_PRESETS.some((p) => p.id === v.id); - if (v.kind === 'image') return typeof v.imageId === 'string' && v.imageId !== ''; - return false; -} - -/** Liest die Wahl; jeder Fehler (privates Fenster, kaputter Wert) ergibt „kein Hintergrund“. */ -export function loadDashboardBackground(userId: string): DashboardBackground { +/** + * Liest eine im Browser gemerkte Wahl aus der Zeit vor der Datenbank- + * speicherung und entfernt den Schluessel — gueltig oder nicht, damit die + * Uebernahme genau einmal passiert. Geprueft mit derselben Regel wie die API + * (`parseDashboardBackground`); kaputte Werte, unbekannte Kennungen und ein + * gesperrter Speicher (privates Fenster) ergeben `null`, nie eine Ausnahme. + */ +export function takeLegacyDashboardBackground(userId: string): DashboardBackground | null { + let raw: string | null = null; try { - const raw = window.localStorage.getItem(storageKey(userId)); - if (!raw) return NO_BACKGROUND; - const parsed: unknown = JSON.parse(raw); - return isValid(parsed) ? parsed : NO_BACKGROUND; + raw = window.localStorage.getItem(legacyStorageKey(userId)); } catch { - return NO_BACKGROUND; - } -} - -export function saveDashboardBackground(userId: string, value: DashboardBackground): void { - try { - window.localStorage.setItem(storageKey(userId), JSON.stringify(value)); - } catch { - // Speicher gesperrt — die Wahl gilt dann nur fuer diese Sitzung. + return null; + } + if (raw === null) return null; + try { + window.localStorage.removeItem(legacyStorageKey(userId)); + } catch { + // Entfernen gesperrt — die Wahl wird trotzdem hoechstens einmal je Sitzung uebernommen. + } + try { + return parseDashboardBackground(JSON.parse(raw)); + } catch { + return null; } } diff --git a/apps/web/src/lib/stores/auth-store.ts b/apps/web/src/lib/stores/auth-store.ts index f3807a0..f1c1389 100644 --- a/apps/web/src/lib/stores/auth-store.ts +++ b/apps/web/src/lib/stores/auth-store.ts @@ -1,3 +1,4 @@ +import type { DashboardBackground } from '@tessera/shared'; import { create } from 'zustand'; import { readableOnAccent } from '@/lib/color'; @@ -9,6 +10,8 @@ export interface AuthUser { tenantId: string; hasAvatar?: boolean; accentColor?: string | null; + /** quick-260928-ujj: Dashboard-Hintergrund aus der Sitzungsantwort; null = nie gewaehlt. */ + dashboardBackground?: DashboardBackground | null; } interface AuthState { diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 1e26bf7..e4a8adb 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -127,7 +127,7 @@ "background": { "button": "Hintergrund", "title": "Hintergrund des Dashboards", - "hint": "Gilt nur für Sie und nur in diesem Browser.", + "hint": "Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.", "builtIn": "Eingebaut", "none": "Keiner", "ownImages": "Eigene Bilder", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 329fee1..f4dad94 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -127,7 +127,7 @@ "background": { "button": "Background", "title": "Dashboard background", - "hint": "Applies only to you and only in this browser.", + "hint": "Applies only to you – on every device you sign in on.", "builtIn": "Built-in", "none": "None", "ownImages": "Your images", diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index df2bb5b..2c10b13 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -760,7 +760,7 @@ werden. | apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. | | apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. | | apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). | -| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. | +| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; seit quick-260928-ujj schreibt der Selbstbedienungsweg `PATCH me/dashboard-background` das Feld `dashboardBackground` (vorher geprueft durch `parseDashboardBackground` aus `@tessera/shared`), ebenfalls `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. | | apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). | | apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". | | apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 831aa43..1589b1e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -194,3 +194,65 @@ export interface ReleaseNoticeResponse { currentRelease: string | null; lastSeenReleaseVersion: string | null; } + +/** + * Dashboard-Hintergrund pro Benutzer (quick-260928-ujj) — EINE Pruefregel + * fuer API und Web. + * + * Gespeichert in `User.dashboardBackground` (JSONB), geschrieben nur ueber + * `PATCH /users/me/dashboard-background`, gelesen mit der Sitzungsantwort + * (`GET /auth/me`, neben `accentColor`). Das Web rendert die Wahl als + * CSS-Hintergrund (`url("...")`) — deshalb ist die Pruefung streng + * (T-ujj-01): `kind` und Preset-Kennung aus einer festen Liste, `imageId` + * nur als UUID (Kennung eines Bilderrahmen-Bildes, `DashboardImage.id`). + * + * Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber + * `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe. + */ +export const DASHBOARD_BACKGROUND_PRESET_IDS = [ + 'mist', + 'pebble', + 'bloom', + 'dunes', + 'mosaic', +] as const; + +export type DashboardBackgroundPresetId = (typeof DASHBOARD_BACKGROUND_PRESET_IDS)[number]; + +export type DashboardBackground = + | { kind: 'none' } + | { kind: 'preset'; id: DashboardBackgroundPresetId } + | { kind: 'image'; imageId: string }; + +const DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Liefert eine gueltige Wahl als FRISCH aufgebautes Objekt (nur die + * erlaubten Felder, Zusatzschluessel fallen weg) oder `null`, wenn der Wert + * keine gueltige Wahl ist. `null` bedeutet beim Lesen „nie gewaehlt“ und + * beim Schreiben „abweisen“. + */ +export function parseDashboardBackground(value: unknown): DashboardBackground | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + const v = value as Record; + if (v.kind === 'none') { + return { kind: 'none' }; + } + if (v.kind === 'preset') { + const id = v.id; + if (typeof id !== 'string') return null; + const known = DASHBOARD_BACKGROUND_PRESET_IDS.find((p) => p === id); + return known ? { kind: 'preset', id: known } : null; + } + if (v.kind === 'image') { + const imageId = v.imageId; + if (typeof imageId !== 'string' || !DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN.test(imageId)) { + return null; + } + return { kind: 'image', imageId }; + } + return null; +}