From 325c5ddbf2d1ee808bc0313cbba635434e40dca0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 29 Sep 2026 13:54:07 +0200 Subject: [PATCH] feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer) - Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen) - Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal) - Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse - Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) --- .../20260929140000_reminder/migration.sql | 73 ++++++ apps/api/prisma/schema.prisma | 25 ++ apps/api/src/app.module.ts | 2 + apps/api/src/reminders/dto/reminder.dto.ts | 28 ++ .../reminders/reminders.controller.spec.ts | 80 ++++++ .../api/src/reminders/reminders.controller.ts | 44 ++++ apps/api/src/reminders/reminders.module.ts | 10 + .../src/reminders/reminders.service.spec.ts | 106 ++++++++ apps/api/src/reminders/reminders.service.ts | 85 ++++++ apps/desktop/src-tauri/src/lib.rs | 246 ++++++++++++++++++ apps/web/src/app/(portal)/page.tsx | 2 + .../dashboard/widget-catalog-modal.test.tsx | 4 +- .../dashboard/widget-registry.test.tsx | 9 +- .../components/dashboard/widget-registry.tsx | 37 +++ .../dashboard/widgets/reminder-form-modal.tsx | 179 +++++++++++++ .../widgets/reminder-widget.test.tsx | 147 +++++++++++ .../dashboard/widgets/reminder-widget.tsx | 108 ++++++++ .../dashboard/widgets/widget-icon.tsx | 6 + .../dashboard/widgets/widget-wrapper.tsx | 5 +- apps/web/src/components/layout/app-shell.tsx | 5 + .../reminders/reminder-notifier.test.tsx | 139 ++++++++++ .../reminders/reminder-notifier.tsx | 112 ++++++++ apps/web/src/lib/reminder-notify.test.ts | 162 ++++++++++++ apps/web/src/lib/reminder-notify.ts | 178 +++++++++++++ apps/web/src/lib/reminder-time.ts | 29 +++ apps/web/src/lib/reminders-api.test.ts | 44 ++++ apps/web/src/lib/reminders-api.ts | 62 +++++ apps/web/src/messages/de.json | 19 ++ apps/web/src/messages/en.json | 19 ++ ...andantentrennung-zugriffsklassifikation.md | 14 +- packages/shared/src/index.ts | 1 + 31 files changed, 1969 insertions(+), 11 deletions(-) create mode 100644 apps/api/prisma/migrations/20260929140000_reminder/migration.sql create mode 100644 apps/api/src/reminders/dto/reminder.dto.ts create mode 100644 apps/api/src/reminders/reminders.controller.spec.ts create mode 100644 apps/api/src/reminders/reminders.controller.ts create mode 100644 apps/api/src/reminders/reminders.module.ts create mode 100644 apps/api/src/reminders/reminders.service.spec.ts create mode 100644 apps/api/src/reminders/reminders.service.ts create mode 100644 apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx create mode 100644 apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/reminder-widget.tsx create mode 100644 apps/web/src/components/reminders/reminder-notifier.test.tsx create mode 100644 apps/web/src/components/reminders/reminder-notifier.tsx create mode 100644 apps/web/src/lib/reminder-notify.test.ts create mode 100644 apps/web/src/lib/reminder-notify.ts create mode 100644 apps/web/src/lib/reminder-time.ts create mode 100644 apps/web/src/lib/reminders-api.test.ts create mode 100644 apps/web/src/lib/reminders-api.ts diff --git a/apps/api/prisma/migrations/20260929140000_reminder/migration.sql b/apps/api/prisma/migrations/20260929140000_reminder/migration.sql new file mode 100644 index 0000000..bda6e46 --- /dev/null +++ b/apps/api/prisma/migrations/20260929140000_reminder/migration.sql @@ -0,0 +1,73 @@ +-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer. +-- +-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets +-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt +-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile. +-- +-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND +-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen +-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit +-- 404 (nie 403). +-- +-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand +-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt +-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben +-- der Faelligkeit setzt beide zurueck. +-- +-- Zeilenschutz, zwei Regeln: +-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage, +-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund- +-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit +-- Benutzer zusaetzlich "userId". +-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_ +-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail- +-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten +-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der +-- Mandantenregel vorbehalten. +-- +-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT +-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun. +-- +-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die +-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe +-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die +-- Anwendungspruefungen im Dienst den Schutz allein. + +-- CreateTable +CREATE TABLE "Reminder" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "dueAt" TIMESTAMP(3) NOT NULL, + "emailEnabled" BOOLEAN NOT NULL DEFAULT false, + "emailSentAt" TIMESTAMP(3), + "emailAttempts" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt"); + +-- CreateIndex +CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt"); + +-- AddForeignKey +ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000) +ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "Reminder" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers +CREATE POLICY system_read_policy ON "Reminder" + FOR SELECT USING (is_system_context()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4efa9cf..cdc7bdf 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -54,6 +54,7 @@ model User { groupMemberships GroupMembership[] moduleGrants ModuleGrant[] customModules CustomModule[] + reminders Reminder[] @@index([tenantId]) @@index([username]) @@ -739,3 +740,27 @@ model CustomModule { @@index([tenantId]) @@index([tenantId, ownerUserId]) } + +// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile +// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API +// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie. +// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch +// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide +// zurueck, damit die E-Mail erneut verschickt wird (D-03). +model Reminder { + id String @id @default(uuid()) + tenantId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + description String @default("") + dueAt DateTime + emailEnabled Boolean @default(false) + emailSentAt DateTime? + emailAttempts Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId, userId, dueAt]) + @@index([dueAt]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 360be30..a5c1145 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module'; import { UserModule } from './user/user.module'; import { ProxmoxModule } from './proxmox/proxmox.module'; import { CustomModulesModule } from './custom-modules/custom-modules.module'; +import { RemindersModule } from './reminders/reminders.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module'; BugReportsModule, ProxmoxModule, CustomModulesModule, + RemindersModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/reminders/dto/reminder.dto.ts b/apps/api/src/reminders/dto/reminder.dto.ts new file mode 100644 index 0000000..a750af2 --- /dev/null +++ b/apps/api/src/reminders/dto/reminder.dto.ts @@ -0,0 +1,28 @@ +import { Transform } from 'class-transformer'; +import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +const trimString = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +/** + * DTO fuer das Anlegen einer Erinnerung (quick-260929-if2). `tenantId`, + * `userId` und die Spuren des E-Mail-Planers gibt es hier nicht: die globale + * ValidationPipe (`whitelist: true`) verwirft sie, der Dienst setzt Mandant und + * Benutzer ausschliesslich aus dem Anmelde-Token (T-IF2-02). + */ +export class CreateReminderDto { + @Transform(trimString) + @IsString() + @IsNotEmpty() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + /** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */ + @IsISO8601({ strict: true }) + dueAt!: string; +} diff --git a/apps/api/src/reminders/reminders.controller.spec.ts b/apps/api/src/reminders/reminders.controller.spec.ts new file mode 100644 index 0000000..6917a55 --- /dev/null +++ b/apps/api/src/reminders/reminders.controller.spec.ts @@ -0,0 +1,80 @@ +import 'reflect-metadata'; +import { ForbiddenException, ValidationPipe } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; +import { CreateReminderDto } from './dto/reminder.dto'; +import { RemindersController } from './reminders.controller'; + +function makeService() { + return { + list: vi.fn(async (..._args: unknown[]) => []), + create: vi.fn(async (..._args: unknown[]) => ({})), + }; +} + +const req = (tenantId?: string) => ({ tenantId }) as any; +const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any; +const proto = RemindersController.prototype as any; + +describe('RemindersController — Rollen', () => { + it.each(['list', 'create'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { + expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); + }); + + it('haengt an Pfad reminders', () => { + expect(Reflect.getMetadata('path', RemindersController)).toBe('reminders'); + }); +}); + +describe('RemindersController — Mandant', () => { + it('reicht req.tenantId und die Benutzerkennung an den Dienst weiter', async () => { + const service = makeService(); + const controller = new RemindersController(service as any); + await controller.list(req('t1'), user); + await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }); + expect(service.list).toHaveBeenCalledWith('t1', 'u1'); + expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']); + }); + + it('wirft ForbiddenException ohne req.tenantId', async () => { + const controller = new RemindersController(makeService() as any); + await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException); + await expect( + controller.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('die globale Pipe verwirft untergeschobene Felder (T-IF2-02)', async () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const out: any = await pipe.transform( + { + title: ' a ', + dueAt: '2099-01-01T10:00:00.000Z', + tenantId: 'evil', + userId: 'evil', + emailSentAt: '2020-01-01T00:00:00.000Z', + emailAttempts: 9, + }, + { type: 'body', metatype: CreateReminderDto }, + ); + expect(out).not.toHaveProperty('tenantId'); + expect(out).not.toHaveProperty('userId'); + expect(out).not.toHaveProperty('emailSentAt'); + expect(out).not.toHaveProperty('emailAttempts'); + expect(out.title).toBe('a'); + }); +}); + +describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => { + it('deklariert list vor jeder :id-Route', () => { + const methods = Object.getOwnPropertyNames(RemindersController.prototype); + const listIdx = methods.indexOf('list'); + expect(listIdx).toBeGreaterThanOrEqual(0); + for (const name of methods) { + const path = Reflect.getMetadata('path', proto[name]); + if (typeof path === 'string' && path.startsWith(':id')) { + expect(listIdx).toBeLessThan(methods.indexOf(name)); + } + } + }); +}); diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts new file mode 100644 index 0000000..412842e --- /dev/null +++ b/apps/api/src/reminders/reminders.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, ForbiddenException, Get, Post, Req } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user'; +import { CreateReminderDto } from './dto/reminder.dto'; +import { RemindersService } from './reminders.service'; + +/** + * Persoenliche Erinnerungen (quick-260929-if2). Jeder angemeldete Benutzer + * verwaltet seine eigenen; fremde Kennungen sind 404 (D-05). Kein `@Roles`, + * kein `@UseModule`: das Widget haengt an keiner Modul-Aktivierung. + * `tenantId` kommt ausschliesslich aus `req.tenantId` (gesetzt vom + * `TenantGuard`), der Benutzer aus dem Token. + * + * ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab. + * Jede statische GET-Route (Aufgabe 3 ergaenzt `email-status`) MUSS ueber jeder + * `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der + * Controller-Test haelt die Reihenfolge fest. + */ +@Controller('reminders') +export class RemindersController { + constructor(private readonly service: RemindersService) {} + + private requireTenantId(req: AuthenticatedRequest): string { + const tenantId = req.tenantId; + if (!tenantId) { + throw new ForbiddenException('Kein Mandantenkontext'); + } + return tenantId; + } + + @Get() + async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) { + return this.service.list(this.requireTenantId(req), user.id); + } + + @Post() + async create( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Body() dto: CreateReminderDto, + ) { + return this.service.create(this.requireTenantId(req), user.id, dto); + } +} diff --git a/apps/api/src/reminders/reminders.module.ts b/apps/api/src/reminders/reminders.module.ts new file mode 100644 index 0000000..e7c05ac --- /dev/null +++ b/apps/api/src/reminders/reminders.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RemindersController } from './reminders.controller'; +import { RemindersService } from './reminders.service'; + +/** Erinnerungen (quick-260929-if2). `PrismaModule` ist global. */ +@Module({ + controllers: [RemindersController], + providers: [RemindersService], +}) +export class RemindersModule {} diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts new file mode 100644 index 0000000..e97f027 --- /dev/null +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -0,0 +1,106 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +// `forTenant` reicht den Klienten durch — die Bindung selbst prueft +// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten gebunden wird. +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { RemindersService } from './reminders.service'; + +function makeFakePrisma() { + const rows = new Map(); + let seq = 0; + const reminder = { + create: vi.fn(async ({ data }: { data: any }) => { + const id = `r-${++seq}`; + const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data }; + rows.set(id, row); + return row; + }), + findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => { + let list = [...rows.values()]; + if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId); + if (where?.userId) list = list.filter((r) => r.userId === where.userId); + if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); + return list; + }), + count: vi.fn(async ({ where }: { where?: any } = {}) => { + return [...rows.values()].filter( + (r) => r.tenantId === where?.tenantId && r.userId === where?.userId, + ).length; + }), + }; + return { reminder, rows }; +} + +function setup() { + const prisma = makeFakePrisma(); + return { prisma, service: new RemindersService(prisma as any) }; +} + +const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString(); + +describe('RemindersService — anlegen', () => { + it('speichert tenantId und userId aus den Argumenten, nie aus dem DTO', async () => { + const { prisma, service } = setup(); + await service.create('t1', 'u1', { + title: 'Zahnarzt', + dueAt: inHours(2), + tenantId: 'evil', + userId: 'evil', + } as any); + const data = prisma.reminder.create.mock.calls[0][0].data; + expect(data.tenantId).toBe('t1'); + expect(data.userId).toBe('u1'); + expect(data.description).toBe(''); + }); + + it('bindet mit Mandant UND Benutzer', async () => { + const { service } = setup(); + await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }); + expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u1'); + }); + + it('lehnt eine vergangene Faelligkeit mit 400 ab', async () => { + const { service } = setup(); + await expect( + service.create('t1', 'u1', { title: 'a', dueAt: inHours(-1) }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lehnt eine Faelligkeit ueber 5 Jahre voraus mit 400 ab', async () => { + const { service } = setup(); + await expect( + service.create('t1', 'u1', { title: 'a', dueAt: inHours(24 * 365 * 5 + 48) }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('die 101. Erinnerung eines Benutzers ergibt 409', async () => { + const { prisma, service } = setup(); + for (let i = 0; i < 100; i++) { + prisma.rows.set(`x${i}`, { id: `x${i}`, tenantId: 't1', userId: 'u1', dueAt: new Date() }); + } + await expect( + service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }), + ).rejects.toBeInstanceOf(ConflictException); + // ein anderer Benutzer ist davon nicht betroffen + await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy(); + }); +}); + +describe('RemindersService — auflisten', () => { + it('liefert nur Zeilen von Mandant und Benutzer, nach Faelligkeit aufsteigend', async () => { + const { prisma, service } = setup(); + await service.create('t1', 'u1', { title: 'spaet', dueAt: inHours(5) }); + await service.create('t1', 'u1', { title: 'frueh', dueAt: inHours(1) }); + await service.create('t1', 'u2', { title: 'fremd', dueAt: inHours(2) }); + await service.create('t2', 'u1', { title: 'anderer Mandant', dueAt: inHours(2) }); + const list = await service.list('t1', 'u1'); + expect(list.map((r: any) => r.title)).toEqual(['frueh', 'spaet']); + const where = prisma.reminder.findMany.mock.calls[0]?.[0]?.where; + expect(where).toEqual({ tenantId: 't1', userId: 'u1' }); + }); +}); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts new file mode 100644 index 0000000..00f1203 --- /dev/null +++ b/apps/api/src/reminders/reminders.service.ts @@ -0,0 +1,85 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import type { CreateReminderDto } from './dto/reminder.dto'; + +/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ +export const REMINDER_SELECT = { + id: true, + title: true, + description: true, + dueAt: true, + emailEnabled: true, + createdAt: true, + updatedAt: true, +}; + +/** E-06: hoechstens so viele Erinnerungen je Benutzer. */ +export const MAX_REMINDERS_PER_USER = 100; +/** E-06: die Faelligkeit darf hoechstens so weit in der Zukunft liegen. */ +const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** + * Persoenliche Erinnerungen (quick-260929-if2). Eine Erinnerung gehoert genau + * einem Benutzer (D-05): fuer fremde oder unbekannte Kennungen antwortet der + * Dienst immer mit 404, nie mit 403 — kein Hinweis darauf, dass es sie gibt. + * + * `tenantId` und `userId` kommen ausschliesslich als Argumente (aus + * `req.tenantId` und dem Anmelde-Token), nie aus dem DTO. + * + * RLS-BINDUNG (Muster DashboardImage, Migration 20260929140000): jede Methode + * bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die + * Regel laesst dann nur eigene Zeilen zu. Zusaetzlich tragen alle `where` + * `tenantId` und `userId` als Anwendungspruefung, solange der RLS-Schalter aus + * ist. + */ +@Injectable() +export class RemindersService { + constructor(private readonly prisma: PrismaService) {} + + /** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */ + async list(tenantId: string, userId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + return tenantPrisma.reminder.findMany({ + where: { tenantId, userId }, + orderBy: { dueAt: 'asc' }, + select: REMINDER_SELECT, + }); + } + + async create(tenantId: string, userId: string, dto: CreateReminderDto) { + const dueAt = this.assertValidDueAt(dto.dueAt); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } }); + if (count >= MAX_REMINDERS_PER_USER) { + throw new ConflictException( + `Es sind höchstens ${MAX_REMINDERS_PER_USER} Erinnerungen möglich`, + ); + } + return tenantPrisma.reminder.create({ + data: { + tenantId, + userId, + title: dto.title, + description: dto.description ?? '', + dueAt, + }, + select: REMINDER_SELECT, + }); + } + + /** E-06: die Faelligkeit muss in der Zukunft und hoechstens 5 Jahre voraus liegen. */ + private assertValidDueAt(iso: string, now: number = Date.now()): Date { + const dueAt = new Date(iso); + if (Number.isNaN(dueAt.getTime())) { + throw new BadRequestException('Ungültige Fälligkeit'); + } + if (dueAt.getTime() <= now) { + throw new BadRequestException('Die Fälligkeit muss in der Zukunft liegen'); + } + if (dueAt.getTime() > now + MAX_AHEAD_MS) { + throw new BadRequestException('Die Fälligkeit liegt zu weit in der Zukunft'); + } + return dueAt; + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a115a2b..25de13f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -324,6 +324,133 @@ fn server_host(url: Option<&str>) -> Option { } } +/// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der +/// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die +/// Bezeichner stammen aus tauri-plugin-notification 2.3.3 +/// (`permissions/autogenerated/commands/notify.toml`, +/// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte +/// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum +/// pinnt ein Test die exakte Menge samt Reihenfolge. +const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [ + "notification:allow-notify", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", +]; + +/// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem +/// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben +/// Adresse soll nichts verdoppeln. +static GRANTED_SERVER_ORIGINS: Mutex> = Mutex::new(Vec::new()); + +/// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die +/// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und +/// Fragment (quick-260929-if2, E-01). +/// +/// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein +/// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt +/// `http://*.example.com/` an und liefert den Host `*.example.com`, der +/// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen +/// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0) +/// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die +/// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur +/// `[::1]:8080` (beides gemessen). +/// +/// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist +/// (der `url`-Crate laesst Standardports weg). +/// +/// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern +/// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der +/// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem +/// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote +/// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm -- +/// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere +/// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit +/// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt +/// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich +/// treffen. Sonst `None`: kein Muster, keine Berechtigung. +fn server_origin_pattern(url: &str) -> Option { + use std::str::FromStr; + let parsed = parse_server_url(url).ok()?; + let host = parsed.host_str()?; + let escaped: String = host + .chars() + .flat_map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' { + vec![c] + } else { + vec!['\\', c] + } + }) + .collect(); + let mut pattern = format!("{}://{}", parsed.scheme(), escaped); + if let Some(port) = parsed.port() { + pattern.push_str(&format!(":{}", port)); + } + let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?; + if checked.test(&parsed) { + Some(pattern) + } else { + None + } +} + +/// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das +/// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen +/// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche +/// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster +/// (Infobereich) eine Windows-Meldung erscheint. +/// +/// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische +/// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe +/// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf +/// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und +/// je Installation verschieden. Die Berechtigung gilt genau fuer den +/// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe +/// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster +/// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie +/// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach +/// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht +/// bis zum App-Neustart (akzeptiert, T-IF2-03). +/// +/// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der +/// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie +/// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview. +/// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt +/// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der +/// unter Windows bei jedem Seitenaufruf "denied" meldet. +/// +/// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden +/// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist +/// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter. +fn grant_server_notifications(app: &AppHandle, url: &str) { + let Some(pattern) = server_origin_pattern(url) else { + eprintln!( + "Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)" + ); + return; + }; + { + let mut granted = match GRANTED_SERVER_ORIGINS.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if granted.contains(&pattern) { + return; + } + granted.push(pattern.clone()); + } + let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications") + .remote(pattern) + .local(false) + .window("main"); + for permission in SERVER_NOTIFICATION_PERMISSIONS { + capability = capability.permission(permission); + } + if let Err(e) = app.add_capability(capability) { + eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e); + } +} + /// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide /// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in /// `update_labels`. @@ -666,6 +793,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> { store.set("server_url", serde_json::json!(normalized)); store.save().map_err(|e| e.to_string())?; + // Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen. + grant_server_notifications(&app, &normalized); apply_server(&app, Some(&normalized)); spawn_version_check(app.clone(), normalized.clone(), false); @@ -801,6 +930,9 @@ pub fn run() { let server_url = stored_server_url(app.handle()); if let Some(url) = &server_url { + // Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die + // gespeicherte Adresse (quick-260929-if2, E-01). + grant_server_notifications(app.handle(), url); if let Some(window) = app.get_webview_window("main") { if let Ok(parsed) = parse_server_url(url) { let _ = window.navigate(with_desktop_marker(&parsed)); @@ -1361,4 +1493,118 @@ mod tests { assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich"); assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600)); } + // --- server_origin_* (quick-260929-if2, E-01) --- + + fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern { + p.parse().expect("Muster muss zerlegbar sein") + } + + fn adresse(s: &str) -> tauri::Url { + tauri::Url::parse(s).expect("Testadresse muss gueltig sein") + } + + #[test] + fn server_origin_entfernt_pfad_query_und_fragment() { + assert_eq!( + server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(), + Some("https://alpha.tessera.ctl.de") + ); + } + + #[test] + fn server_origin_behaelt_nicht_standard_port() { + assert_eq!( + server_origin_pattern("http://192.168.13.12:8080/").as_deref(), + Some("http://192.168.13.12:8080") + ); + } + + #[test] + fn server_origin_laesst_standardport_weg() { + assert_eq!( + server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(), + Some("https://alpha.tessera.ctl.de") + ); + } + + #[test] + fn server_origin_mit_und_ohne_schlussstrich_gleich() { + assert_eq!( + server_origin_pattern("https://tessera.example.de/"), + server_origin_pattern("https://tessera.example.de") + ); + assert!(server_origin_pattern("https://tessera.example.de").is_some()); + } + + #[test] + fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() { + assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None); + assert_eq!(server_origin_pattern("das ist keine adresse"), None); + assert_eq!(server_origin_pattern(""), None); + } + + #[test] + fn server_origin_ipv6_wird_maskiert() { + assert_eq!( + server_origin_pattern("http://[::1]:8080/").as_deref(), + Some(r"http://\[\:\:1\]:8080") + ); + } + + #[test] + fn server_origin_platzhalter_host_wird_maskiert() { + assert_eq!( + server_origin_pattern("http://*.example.com/").as_deref(), + Some(r"http://\*.example.com") + ); + } + + #[test] + fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() { + let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap()); + assert!(p.test(&adresse("https://alpha.tessera.ctl.de/"))); + assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c"))); + } + + #[test] + fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() { + let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap()); + assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/"))); + assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/"))); + assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/"))); + assert!(!p.test(&adresse("https://beta.tessera.ctl.de/"))); + + let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap()); + assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a"))); + assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/"))); + assert!(!mit_port.test(&adresse("http://192.168.13.12/"))); + } + + #[test] + fn server_origin_platzhalter_host_trifft_keine_subdomain() { + let p = muster(&server_origin_pattern("http://*.example.com/").unwrap()); + assert!(!p.test(&adresse("http://a.example.com/"))); + } + + #[test] + fn server_origin_ipv6_trifft_nur_die_eigene_adresse() { + let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap()); + assert!(p.test(&adresse("http://[::1]:8080/dashboard"))); + assert!(!p.test(&adresse("http://[::2]:8080/"))); + assert!(!p.test(&adresse("http://[::1]/"))); + } + + #[test] + fn server_origin_berechtigungen_sind_genau_diese_drei() { + // Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default` + // oder eine Platzhalter-Berechtigung laesst den Test rot werden. + assert_eq!( + SERVER_NOTIFICATION_PERMISSIONS, + [ + "notification:allow-notify", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + ] + ); + } } diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index aaa3b89..994291d 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -20,6 +20,7 @@ import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; +import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -39,6 +40,7 @@ registerWidget('favorites', FavoritesWidget); registerWidget('picture-frame', PictureFrameWidget); registerWidget('xframe', XframeWidget); registerWidget('proxmox', ProxmoxWidget); +registerWidget('reminder', ReminderWidget); /** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */ interface ActiveModule { diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx index 566aa67..9f89789 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx @@ -151,11 +151,11 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', ).map((c) => c.getAttribute('data-widget-type')); } - it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => { + it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => { render(); expect(catalogTypes()).toEqual([...WIDGET_TYPES]); - expect(catalogTypes().at(-1)).toBe('proxmox'); + expect(catalogTypes().at(-1)).toBe('reminder'); expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]); }); diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index 457ae33..4377c9c 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -31,6 +31,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [ 'xframe', // Proxmox — erste Modul-Kachel (quick-260924-i8v) 'proxmox', + // Erinnerungen (quick-260929-if2) + 'reminder', ]; describe('WIDGET_CONSTRAINTS (DASH-11)', () => { @@ -66,7 +68,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); }); - it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => { + it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => { // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8 @@ -82,6 +84,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { 'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 }, xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 }, proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 }, + reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, }); let counted = 0; @@ -91,7 +94,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { counted++; } } - expect(counted).toBe(40); + expect(counted).toBe(44); }); }); @@ -107,7 +110,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]); }); - it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { + it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES); }); diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index c929778..2455b3e 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -19,6 +19,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; * picture-frame: Bilderrahmen (quick-260921-pi9). * xframe: Webseite als Rahmen (quick-260921-qd3). * proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v). + * reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2). */ export type { WidgetType }; @@ -89,6 +90,12 @@ export const WIDGET_CONSTRAINTS: Record< // zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte // und Namen. proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 }, + // quick-260929-if2: Liste plus Knopfzeile „Neue Erinnerung“. Im 48er-Raster + // sind 8 Spalten = rund 230 px die kleinste Breite, in der Titel, Zeitangabe + // und die umbrechenden Knoepfe noch benutzbar sind (wie Notiz und Favoriten); + // 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 = + // rund 340x270 px: drei bis vier Erinnerungen auf einmal. + reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, }; /** @@ -358,6 +365,27 @@ function ProxmoxIcon({ className }: { className?: string }) { ); } +function ReminderIcon({ className }: { className?: string }) { + return ( + + ); +} + /** * Registry of all widget types. Used by the widget catalog modal * and the grid renderer to look up components and constraints. @@ -455,6 +483,15 @@ export const WIDGET_REGISTRY: Record = { moduleSlug: WIDGET_MODULE_SLUGS.proxmox, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, + reminder: { + type: 'reminder', + nameKey: 'reminder.name', + descriptionKey: 'reminder.description', + icon: ReminderIcon, + ...WIDGET_CONSTRAINTS.reminder, + moduleSlug: WIDGET_MODULE_SLUGS.reminder, + component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt + }, }; /** diff --git a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx new file mode 100644 index 0000000..ad58935 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; +import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time'; +import { ReminderRequestError, createReminder } from '@/lib/reminders-api'; + +interface ReminderFormModalProps { + onClose: () => void; + /** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */ + onSaved: () => void; +} + +const TITLE_MAX = 200; +const DESCRIPTION_MAX = 2000; + +/** + * Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind + * Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt. + * + * Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem + * `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr + * macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild + * `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`. + * + * ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im + * Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf + * eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim + * Laden der Seite. + */ +export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) { + const t = useTranslations('widgets'); + const initial = useRef(defaultNewReminderInputs(new Date())).current; + const [date, setDate] = useState(initial.date); + const [time, setTime] = useState(initial.time); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const titleRef = useRef(null); + + useEffect(() => { + titleRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + // Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04). + requestBrowserPermissionOnce(); + setError(null); + + const trimmedTitle = title.trim(); + if (trimmedTitle === '') { + setError(t('reminder.titleRequired')); + return; + } + const dueAt = localInputsToIso(date, time); + // Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen. + if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) { + setError(t('reminder.pastError')); + return; + } + + setSaving(true); + try { + await createReminder({ title: trimmedTitle, description: description.trim(), dueAt }); + onSaved(); + onClose(); + } catch (err) { + setError( + err instanceof ReminderRequestError && err.status === 409 + ? t('reminder.limitReached') + : t('reminder.saveError'), + ); + } finally { + setSaving(false); + } + }; + + if (typeof document === 'undefined') return null; + + return createPortal( +
+
+

+ {t('reminder.formTitle')} +

+
+
+
+ + setDate(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+
+ + setTime(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+
+ +
+ + setTitle(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+ +
+ +