feat(kalender): Seite für die Kalenderkonten

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:05:07 +02:00
co-authored by Claude Opus 5
parent 9946e7c308
commit 0c67b73a62
5 changed files with 449 additions and 0 deletions
@@ -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<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function felder(werte: Record<string, string>): FormData {
const daten = new FormData()
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
return daten
}
return (
<Card>
<CardKopf className="flex-row flex-wrap items-center gap-2">
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
</CardKopf>
<CardInhalt className="flex flex-col gap-4">
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
<dt className="text-gedaempft-vordergrund">Konto</dt>
<dd>{konto.extKontoId}</dd>
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
<dd>{konto.termine}</dd>
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
</dl>
{konto.letzterFehler && (
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
{konto.letzterFehler}
</p>
)}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`name-${konto.id}`}
className="text-gedaempft-vordergrund text-xs"
>
Bezeichnung
</label>
<Input
id={`name-${konto.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
disabled={laeuft}
/>
</div>
<Button
art="umriss"
disabled={laeuft || name.trim() === konto.anzeigename}
onClick={() =>
mitRueckmeldung(() =>
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
)
}
>
Speichern
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
art="umriss"
disabled={laeuft}
onClick={() =>
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
Jetzt abgleichen
</Button>
<Button
art="gefahr"
className="ml-auto"
disabled={laeuft}
onBlur={() => setTrennenBestaetigt(false)}
onClick={() => {
if (!trennenBestaetigt) {
setTrennenBestaetigt(true)
return
}
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
}}
>
<Unplug aria-hidden />
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
</Button>
</div>
{trennenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
unangetastet.
</p>
)}
</CardInhalt>
</Card>
)
}
+33
View File
@@ -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
}
+140
View File
@@ -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<KalenderAnbieter, string> = {
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 (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarDays className="size-6" aria-hidden />
Kalenderkonten
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
schreibt er Zeitblöcke in denselben Kalender zurück.
</p>
</header>
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
<Card>
<CardKopf>
<CardTitel className="text-base">Kalender verbinden</CardTitel>
<CardBeschreibung>
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
Der Planer speichert keine Zugangsdaten im Klartext.
</CardBeschreibung>
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
{verfuegbar.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
</p>
) : (
<div className="flex flex-wrap gap-2">
{verfuegbar.map((anbieter) => (
<Button key={anbieter} alsKind>
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
<Plus aria-hidden />
{ANBIETER_NAME[anbieter]} verbinden
</a>
</Button>
))}
</div>
)}
{fehlendKonfiguriert.length > 0 && (
<p className="text-gedaempft-vordergrund text-xs">
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
Umgebungsvariablen.
</p>
)}
</CardInhalt>
</Card>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Verbundene Konten</h2>
{konten.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Kalender verbunden.
</CardInhalt>
</Card>
) : (
konten.map((konto) => (
<KontoKarte
key={konto.id}
konto={{
id: konto.id,
anbieter: konto.anbieter,
anbieterName: ANBIETER_NAME[konto.anbieter],
anzeigename: konto.anzeigename,
extKontoId: konto.extKontoId,
termine: konto._count.termine,
letzterAbgleich: konto.letzterAbgleich
? alsDatumUhrzeit(konto.letzterAbgleich)
: null,
letzterFehler: konto.letzterFehler,
}}
/>
))
)}
</section>
</div>
)
}
+1
View File
@@ -33,6 +33,7 @@ export function Kopfleiste({
{ pfad: '/projekte', name: 'Projekte' }, { pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' }, { pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' }, { pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
] ]
+126
View File
@@ -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<Rueckmeldung> {
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<Rueckmeldung> {
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<Rueckmeldung> {
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.`,
}
}