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:
2026-09-28 22:13:49 +02:00
parent 76f6d87973
commit 0aaa15240d
16 changed files with 333 additions and 66 deletions
@@ -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;
+4
View File
@@ -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[]
+6 -1
View File
@@ -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,
};
+43
View File
@@ -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.