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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -46,6 +46,10 @@ model User {
|
|||||||
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
||||||
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
||||||
lastSeenReleaseVersion String?
|
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[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { parseDashboardBackground } from '@tessera/shared';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -331,6 +332,7 @@ export class AuthService {
|
|||||||
ldapDn: true,
|
ldapDn: true,
|
||||||
avatarPath: true,
|
avatarPath: true,
|
||||||
accentColor: true,
|
accentColor: true,
|
||||||
|
dashboardBackground: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,10 +340,13 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...publicFields,
|
...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,
|
isLocalUser: !!passwordHash && !ldapDn,
|
||||||
hasAvatar: !!avatarPath,
|
hasAvatar: !!avatarPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
compareReleaseVersions,
|
compareReleaseVersions,
|
||||||
|
type DashboardBackground,
|
||||||
|
parseDashboardBackground,
|
||||||
parseReleaseVersion,
|
parseReleaseVersion,
|
||||||
type ReleaseNoticeResponse,
|
type ReleaseNoticeResponse,
|
||||||
} from '@tessera/shared';
|
} from '@tessera/shared';
|
||||||
@@ -62,6 +64,10 @@ function resolveAvatarsDir(): string {
|
|||||||
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
||||||
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
||||||
* `where: { id: currentUser.id }`.
|
* `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')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -483,6 +489,43 @@ export class UserController {
|
|||||||
return { success: true };
|
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
|
* GET /users/me/avatar
|
||||||
* Stream the current user's avatar image.
|
* Stream the current user's avatar image.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
|||||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
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 { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
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
|
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
|
||||||
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
||||||
const [gridInset, setGridInset] = useState(0);
|
const [gridInset, setGridInset] = useState(0);
|
||||||
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
|
// Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
// Datenbank gespeichert und mit der Sitzung geladen.
|
||||||
const { background, choose: chooseBackground } = useDashboardBackground(userId);
|
const { background, choose: chooseBackground } = useDashboardBackground();
|
||||||
const hasBackground = background.kind !== 'none';
|
const hasBackground = background.kind !== 'none';
|
||||||
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
||||||
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('liest die Wahl aus dem Auth-Store; null ergibt „kein Hintergrund“', async () => {
|
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();
|
const { result } = await renderBackgroundHook();
|
||||||
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
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' });
|
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(result.current.background).toEqual({ kind: 'preset', id: 'mosaic' });
|
||||||
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' });
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Fehlerantwort', () => updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' })],
|
[
|
||||||
|
'Fehlerantwort',
|
||||||
|
() =>
|
||||||
|
updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' }),
|
||||||
|
],
|
||||||
['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))],
|
['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))],
|
||||||
])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => {
|
])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => {
|
||||||
arrange();
|
arrange();
|
||||||
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }) });
|
useAuthStore.setState({
|
||||||
|
user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }),
|
||||||
|
});
|
||||||
const { result } = await renderBackgroundHook();
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -86,7 +97,10 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }));
|
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 () => {
|
it('Server-Wert null und alte localStorage-Wahl: wird genau einmal gespeichert, Schluessel entfernt', async () => {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { updateDashboardBackgroundAction } from '@/lib/auth-actions';
|
||||||
import {
|
import {
|
||||||
BACKGROUND_PRESETS,
|
BACKGROUND_PRESETS,
|
||||||
type DashboardBackground,
|
type DashboardBackground,
|
||||||
loadDashboardBackground,
|
|
||||||
NO_BACKGROUND,
|
NO_BACKGROUND,
|
||||||
presetBackground,
|
presetBackground,
|
||||||
saveDashboardBackground,
|
takeLegacyDashboardBackground,
|
||||||
} from '@/lib/dashboard-background';
|
} from '@/lib/dashboard-background';
|
||||||
import {
|
import {
|
||||||
type DashboardImageMeta,
|
type DashboardImageMeta,
|
||||||
@@ -17,23 +17,61 @@ import {
|
|||||||
fetchDashboardImages,
|
fetchDashboardImages,
|
||||||
uploadDashboardImage,
|
uploadDashboardImage,
|
||||||
} from '@/lib/dashboard-images-api';
|
} from '@/lib/dashboard-images-api';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
|
||||||
/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */
|
/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */
|
||||||
export function useDashboardBackground(userId: string | null | undefined) {
|
function setStoredBackground(userId: string, value: DashboardBackground | null) {
|
||||||
const [background, setBackground] = useState<DashboardBackground>(NO_BACKGROUND);
|
const current = useAuthStore.getState().user;
|
||||||
|
if (!current || current.id !== userId) return;
|
||||||
|
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
if (userId) setBackground(loadDashboardBackground(userId));
|
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
|
||||||
}, [userId]);
|
*
|
||||||
|
* 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<string | null>(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(
|
const choose = useCallback(
|
||||||
(value: DashboardBackground) => {
|
(value: DashboardBackground) => {
|
||||||
setBackground(value);
|
if (!userId) return;
|
||||||
if (userId) saveDashboardBackground(userId, value);
|
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 };
|
return { background, choose };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +202,17 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
|
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
|
||||||
selected ? 'border-transparent ring-2 ring-foreground' : 'border-border group-hover:border-input-strong'
|
selected
|
||||||
|
? 'border-transparent ring-2 ring-foreground'
|
||||||
|
: 'border-border group-hover:border-input-strong'
|
||||||
}`}
|
}`}
|
||||||
style={preview}
|
style={preview}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</span>
|
</span>
|
||||||
<span className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}>
|
<span
|
||||||
|
className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +229,18 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
className="btn btn-subtle"
|
className="btn btn-subtle"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
<circle cx="9" cy="9" r="2" />
|
<circle cx="9" cy="9" r="2" />
|
||||||
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
||||||
@@ -206,8 +259,25 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
|
|
||||||
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
|
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
|
||||||
<ul className="mt-2 grid grid-cols-3 gap-3">
|
<ul className="mt-2 grid grid-cols-3 gap-3">
|
||||||
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
|
{option(
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
|
'none',
|
||||||
|
t('none'),
|
||||||
|
NO_BACKGROUND,
|
||||||
|
{ backgroundColor: 'var(--background)' },
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="m6.5 17.5 11-11" />
|
||||||
|
</svg>,
|
||||||
)}
|
)}
|
||||||
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
|
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
|
||||||
const css = (dark ? preset.dark : preset.light) ?? preset.light;
|
const css = (dark ? preset.dark : preset.light) ?? preset.light;
|
||||||
@@ -247,7 +317,20 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
) : (
|
) : (
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M12 5v14" />
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
|
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function Header() {
|
|||||||
tenantId: u.tenantId,
|
tenantId: u.tenantId,
|
||||||
hasAvatar: u.hasAvatar,
|
hasAvatar: u.hasAvatar,
|
||||||
accentColor: u.accentColor,
|
accentColor: u.accentColor,
|
||||||
|
dashboardBackground: u.dashboardBackground ?? null,
|
||||||
});
|
});
|
||||||
} else if (state.status === 'unauthenticated') {
|
} else if (state.status === 'unauthenticated') {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import type { DashboardBackground } from '@tessera/shared';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export interface AuthUser {
|
|||||||
isLocalUser?: boolean;
|
isLocalUser?: boolean;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
/** quick-260928-ujj: null = nie gewaehlt. */
|
||||||
|
dashboardBackground?: DashboardBackground | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
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 =
|
export type SessionState =
|
||||||
| { status: 'authenticated'; user: AuthUser }
|
| { status: 'authenticated'; user: AuthUser }
|
||||||
| { status: 'unauthenticated' }
|
| { status: 'unauthenticated' }
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ describe('dashboard-background (Design „Mosaik“)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('die eingebauten Hintergruende sind deckungsgleich mit der Pruefregel in @tessera/shared', () => {
|
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)', () => {
|
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
* Dashboard-Hintergrund (Design „Mosaik“).
|
||||||
*
|
*
|
||||||
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
||||||
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
|
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
|
||||||
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
||||||
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
|
* (`/dashboard/images`).
|
||||||
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
|
*
|
||||||
* kann folgen, wenn die Funktion bleibt.
|
* 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 =
|
export type { DashboardBackground };
|
||||||
| { kind: 'none' }
|
|
||||||
| { kind: 'preset'; id: BackgroundPresetId }
|
export type BackgroundPresetId = DashboardBackgroundPresetId;
|
||||||
| { kind: 'image'; imageId: string };
|
|
||||||
|
|
||||||
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
|
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
|
||||||
|
|
||||||
@@ -22,7 +30,8 @@ function svgUrl(svg: string): string {
|
|||||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
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 {
|
function bloom(base: string, a: string, b: string, c: string): string {
|
||||||
return svgUrl(
|
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;
|
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}`;
|
return `tessera.dashboardBackground.${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValid(value: unknown): value is DashboardBackground {
|
/**
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
* Liest eine im Browser gemerkte Wahl aus der Zeit vor der Datenbank-
|
||||||
const v = value as Record<string, unknown>;
|
* speicherung und entfernt den Schluessel — gueltig oder nicht, damit die
|
||||||
if (v.kind === 'none') return true;
|
* Uebernahme genau einmal passiert. Geprueft mit derselben Regel wie die API
|
||||||
if (v.kind === 'preset') return BACKGROUND_PRESETS.some((p) => p.id === v.id);
|
* (`parseDashboardBackground`); kaputte Werte, unbekannte Kennungen und ein
|
||||||
if (v.kind === 'image') return typeof v.imageId === 'string' && v.imageId !== '';
|
* gesperrter Speicher (privates Fenster) ergeben `null`, nie eine Ausnahme.
|
||||||
return false;
|
*/
|
||||||
}
|
export function takeLegacyDashboardBackground(userId: string): DashboardBackground | null {
|
||||||
|
let raw: string | null = null;
|
||||||
/** Liest die Wahl; jeder Fehler (privates Fenster, kaputter Wert) ergibt „kein Hintergrund“. */
|
|
||||||
export function loadDashboardBackground(userId: string): DashboardBackground {
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey(userId));
|
raw = window.localStorage.getItem(legacyStorageKey(userId));
|
||||||
if (!raw) return NO_BACKGROUND;
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
return isValid(parsed) ? parsed : NO_BACKGROUND;
|
|
||||||
} catch {
|
} catch {
|
||||||
return NO_BACKGROUND;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
if (raw === null) return null;
|
||||||
|
|
||||||
export function saveDashboardBackground(userId: string, value: DashboardBackground): void {
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(storageKey(userId), JSON.stringify(value));
|
window.localStorage.removeItem(legacyStorageKey(userId));
|
||||||
} catch {
|
} catch {
|
||||||
// Speicher gesperrt — die Wahl gilt dann nur fuer diese Sitzung.
|
// Entfernen gesperrt — die Wahl wird trotzdem hoechstens einmal je Sitzung uebernommen.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseDashboardBackground(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DashboardBackground } from '@tessera/shared';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { readableOnAccent } from '@/lib/color';
|
import { readableOnAccent } from '@/lib/color';
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ export interface AuthUser {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
/** quick-260928-ujj: Dashboard-Hintergrund aus der Sitzungsantwort; null = nie gewaehlt. */
|
||||||
|
dashboardBackground?: DashboardBackground | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"button": "Hintergrund",
|
"button": "Hintergrund",
|
||||||
"title": "Hintergrund des Dashboards",
|
"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",
|
"builtIn": "Eingebaut",
|
||||||
"none": "Keiner",
|
"none": "Keiner",
|
||||||
"ownImages": "Eigene Bilder",
|
"ownImages": "Eigene Bilder",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"button": "Background",
|
"button": "Background",
|
||||||
"title": "Dashboard 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",
|
"builtIn": "Built-in",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"ownImages": "Your images",
|
"ownImages": "Your images",
|
||||||
|
|||||||
@@ -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/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 | 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/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 | 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/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). |
|
| 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). |
|
||||||
|
|||||||
@@ -194,3 +194,65 @@ export interface ReleaseNoticeResponse {
|
|||||||
currentRelease: string | null;
|
currentRelease: string | null;
|
||||||
lastSeenReleaseVersion: 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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user