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:
co-authored by
Claude Opus 5
parent
9946e7c308
commit
0c67b73a62
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user