From 0c67b73a627a54c38e2f02724b786b888025ed21 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:05:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(kalender):=20Seite=20f=C3=BCr=20die=20Kale?= =?UTF-8?q?nderkonten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in die where-Bedingung, nicht in einen nachträglichen Vergleich. Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite das ausdrücklich, statt den Knopf wortlos wegzulassen. Co-Authored-By: Claude Opus 5 --- src/app/(app)/kalenderkonten/konto-karte.tsx | 149 +++++++++++++++++++ src/app/(app)/kalenderkonten/meldung.tsx | 33 ++++ src/app/(app)/kalenderkonten/page.tsx | 140 +++++++++++++++++ src/components/kopfleiste.tsx | 1 + src/server/aktionen/kalenderkonten.ts | 126 ++++++++++++++++ 5 files changed, 449 insertions(+) create mode 100644 src/app/(app)/kalenderkonten/konto-karte.tsx create mode 100644 src/app/(app)/kalenderkonten/meldung.tsx create mode 100644 src/app/(app)/kalenderkonten/page.tsx create mode 100644 src/server/aktionen/kalenderkonten.ts diff --git a/src/app/(app)/kalenderkonten/konto-karte.tsx b/src/app/(app)/kalenderkonten/konto-karte.tsx new file mode 100644 index 0000000..ff11214 --- /dev/null +++ b/src/app/(app)/kalenderkonten/konto-karte.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Loader2, RefreshCw, Unplug } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { + jetztAbgleichen, + kontoTrennen, + kontoUmbenennen, + type Rueckmeldung, +} from '@/server/aktionen/kalenderkonten' + +export type KontoAnzeige = { + id: string + anbieter: 'GRAPH' | 'GOOGLE' + anbieterName: string + anzeigename: string + extKontoId: string + termine: number + letzterAbgleich: string | null + letzterFehler: string | null +} + +export function KontoKarte({ konto }: { konto: KontoAnzeige }) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [name, setName] = useState(konto.anzeigename) + const [trennenBestaetigt, setTrennenBestaetigt] = useState(false) + + function mitRueckmeldung(tun: () => Promise) { + starte(async () => { + const antwort = await tun() + if (antwort.ok) { + toast.success(antwort.meldung) + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + } + + function felder(werte: Record): FormData { + const daten = new FormData() + for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert) + return daten + } + + return ( + + + {konto.anzeigename} + {konto.anbieterName} + {konto.letzterFehler && Fehler} + + + +
+
Konto
+
{konto.extKontoId}
+
Gespiegelte Termine
+
{konto.termine}
+
Letzter Abgleich
+
{konto.letzterAbgleich ?? 'noch nie'}
+
+ + {konto.letzterFehler && ( +

+ {konto.letzterFehler} +

+ )} + +
+
+ + setName(e.target.value)} + maxLength={60} + disabled={laeuft} + /> +
+ +
+ +
+ + + +
+ + {trennenBestaetigt && ( +

+ Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles + unangetastet. +

+ )} +
+
+ ) +} diff --git a/src/app/(app)/kalenderkonten/meldung.tsx b/src/app/(app)/kalenderkonten/meldung.tsx new file mode 100644 index 0000000..8ac2ea5 --- /dev/null +++ b/src/app/(app)/kalenderkonten/meldung.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +/** + * Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort + * hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem + * Neuladen erneut. + */ +export function Meldung({ + ok, + fehler, +}: { + ok?: string | undefined + fehler?: string | undefined +}) { + const router = useRouter() + const gezeigt = useRef(false) + + useEffect(() => { + if (gezeigt.current) return + if (!ok && !fehler) return + + gezeigt.current = true + if (ok) toast.success(ok) + if (fehler) toast.error(fehler) + router.replace('/kalenderkonten') + }, [ok, fehler, router]) + + return null +} diff --git a/src/app/(app)/kalenderkonten/page.tsx b/src/app/(app)/kalenderkonten/page.tsx new file mode 100644 index 0000000..d124171 --- /dev/null +++ b/src/app/(app)/kalenderkonten/page.tsx @@ -0,0 +1,140 @@ +import type { Metadata } from 'next' +import { CalendarDays, Plus } from 'lucide-react' +import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte' +import { Meldung } from '@/app/(app)/kalenderkonten/meldung' +import { Button } from '@/components/ui/button' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' +import { erlaubteKalenderAnbieter } from '@/server/instanz' +import { umgebung } from '@/server/umgebung' + +export const metadata: Metadata = { title: 'Kalenderkonten' } + +const ANBIETER_NAME: Record = { + GRAPH: 'Microsoft 365', + GOOGLE: 'Google', +} + +export default async function KalenderkontenSeite({ + searchParams, +}: { + searchParams: Promise<{ ok?: string; fehler?: string }> +}) { + const benutzer = await verlangeAnmeldung('/kalenderkonten') + const parameter = await searchParams + const einst = umgebung() + + // Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie + // benutzbar sind, hängt zusätzlich an der Konfiguration. + const erlaubt = erlaubteKalenderAnbieter() + const verfuegbar = erlaubt.filter((anbieter) => + anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv, + ) + const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a)) + + const konten = await db.kalenderkonto.findMany({ + where: { benutzerId: benutzer.id }, + orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }], + select: { + id: true, + anbieter: true, + anzeigename: true, + extKontoId: true, + letzterAbgleich: true, + letzterFehler: true, + _count: { select: { termine: true } }, + }, + }) + + return ( +
+
+

+ + Kalenderkonten +

+

+ Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4 + schreibt er Zeitblöcke in denselben Kalender zurück. +

+
+ + + + + + Kalender verbinden + + Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück. + Der Planer speichert keine Zugangsdaten im Klartext. + + + + {verfuegbar.length === 0 ? ( +

+ In dieser Instanz ist kein Kalenderanbieter eingerichtet. +

+ ) : ( +
+ {verfuegbar.map((anbieter) => ( + + ))} +
+ )} + + {fehlendKonfiguriert.length > 0 && ( +

+ {fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier + erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt + Umgebungsvariablen. +

+ )} +
+
+ +
+

Verbundene Konten

+ + {konten.length === 0 ? ( + + + Noch kein Kalender verbunden. + + + ) : ( + konten.map((konto) => ( + + )) + )} +
+
+ ) +} diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx index 0b8d027..f547cc3 100644 --- a/src/components/kopfleiste.tsx +++ b/src/components/kopfleiste.tsx @@ -33,6 +33,7 @@ export function Kopfleiste({ { pfad: '/projekte', name: 'Projekte' }, { pfad: '/ideen', name: 'Ideen' }, { pfad: '/bereiche', name: 'Bereiche' }, + { pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, ] diff --git a/src/server/aktionen/kalenderkonten.ts b/src/server/aktionen/kalenderkonten.ts new file mode 100644 index 0000000..2301719 --- /dev/null +++ b/src/server/aktionen/kalenderkonten.ts @@ -0,0 +1,126 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { kontoAbgleichen } from '@/server/kalender/abgleich' + +export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string } + +const eingabe = z.object({ id: z.string().min(1) }) + +/** + * Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die + * Kennung geht in die where-Bedingung, nicht in einen nachträglichen + * Vergleich. + */ +async function eigenesKonto(id: string, benutzerId: string) { + return db.kalenderkonto.findFirst({ + where: { id, benutzerId }, + select: { id: true, anzeigename: true, anbieter: true }, + }) +} + +async function akteurOderFehler() { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) { + return { antwort: { ok: false as const, fehler: fehler.message } } + } + throw fehler + } +} + +export async function jetztAbgleichen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + const bericht = await kontoAbgleichen(konto.id) + revalidatePath('/kalenderkonten') + + if (!bericht.ok) { + return { ok: false, fehler: bericht.fehler ?? 'Der Abgleich schlug fehl.' } + } + return { + ok: true, + meldung: `„${konto.anzeigename}": ${bericht.geaendert} Termin(e) gelesen, ${bericht.entfernt} entfernt.`, + } +} + +const umbenennen = z.object({ + id: z.string().min(1), + anzeigename: z.string().trim().min(2, 'Mindestens zwei Zeichen.').max(60), +}) + +export async function kontoUmbenennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = umbenennen.safeParse({ + id: daten.get('id'), + anzeigename: daten.get('anzeigename'), + }) + if (!gelesen.success) { + return { ok: false, fehler: 'Bitte einen Namen mit mindestens zwei Zeichen angeben.' } + } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { anzeigename: gelesen.data.anzeigename }, + }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.umbenannt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { vorher: konto.anzeigename, nachher: gelesen.data.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + return { ok: true, meldung: 'Name gespeichert.' } +} + +/** + * Trennt ein Kalenderkonto. Die gespiegelten Termine verschwinden mit ihm — + * sie stammen aus dem Kalender und sind dort weiterhin vorhanden. + */ +export async function kontoTrennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.delete({ where: { id: konto.id } }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.getrennt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { anbieter: konto.anbieter, anzeigename: konto.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + revalidatePath('/') + return { + ok: true, + meldung: `„${konto.anzeigename}" getrennt. Die Termine bleiben im Kalender selbst erhalten.`, + } +}