feat(kalender): OAuth und Abgleich für Graph und Google
Verbinden, Token erneuern und seitenweise abrufen — mit blankem fetch, ohne SDK. Es geht um drei Aufrufe je Anbieter, und die Antworten werden ohnehin mit zod geprüft; ein SDK brächte vor allem Abhängigkeiten und blinde Flecken. Entscheidungen: - Der Zustandswert im OAuth-Ablauf ist mit AUTH_SECRET signiert und trägt die Benutzerkennung. Ohne das ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen. - Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest. Sonst müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist genau der Schritt, an dem Einrichtung scheitert. - Läuft der Abgleichverweis ab (beide Anbieter antworten mit 410), wird er verworfen und von vorn gelesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. - Googles syncToken trägt die Zeitgrenzen nicht mit; Folgeabgleiche liefern also auch Änderungen außerhalb unseres Fensters. Was daneben liegt, wird verworfen — sonst wüchse der Spiegel unbegrenzt. Die Instanzregel greift serverseitig: Google gibt es nur daheim, in der Firmeninstanz liefert die Route 404 statt eines Hinweises. Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der enthält das Client-Secret oft im Echo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6fda2a579e
commit
9946e7c308
@@ -0,0 +1,136 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||||
|
import { kontoAbgleichen } from '@/server/kalender/abgleich'
|
||||||
|
import { codeEinloesen } from '@/server/kalender/oauth'
|
||||||
|
import {
|
||||||
|
alsAnbieter,
|
||||||
|
faehigkeitFuer,
|
||||||
|
istAnbieter,
|
||||||
|
zustandLesen,
|
||||||
|
} from '@/server/kalender/verbinden'
|
||||||
|
import { verschluesseln } from '@/server/krypto'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
|
||||||
|
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
|
||||||
|
ziel.searchParams.set(art, nachricht)
|
||||||
|
return NextResponse.redirect(ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
|
||||||
|
*
|
||||||
|
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
|
||||||
|
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ anbieter: string }> },
|
||||||
|
) {
|
||||||
|
const { anbieter: roh } = await params
|
||||||
|
if (!istAnbieter(roh)) notFound()
|
||||||
|
|
||||||
|
const anbieter = alsAnbieter(roh)
|
||||||
|
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||||
|
|
||||||
|
const adresse = new URL(anfrage.url)
|
||||||
|
const fehlerAnbieter = adresse.searchParams.get('error')
|
||||||
|
if (fehlerAnbieter) {
|
||||||
|
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
|
||||||
|
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const zustand = zustandLesen(adresse.searchParams.get('state'))
|
||||||
|
if (!zustand || zustand.anbieter !== anbieter) {
|
||||||
|
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = adresse.searchParams.get('code')
|
||||||
|
if (!code) {
|
||||||
|
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzer = await db.benutzer.findUnique({
|
||||||
|
where: { id: zustand.benutzerId },
|
||||||
|
select: { id: true, email: true, aktiv: true },
|
||||||
|
})
|
||||||
|
if (!benutzer?.aktiv) {
|
||||||
|
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const satz = await codeEinloesen(anbieter, code)
|
||||||
|
|
||||||
|
if (!satz.aktualisierungstoken) {
|
||||||
|
// Ohne das wäre die Verbindung nach einer Stunde tot.
|
||||||
|
return zurueck(
|
||||||
|
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
|
||||||
|
'fehler',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
|
||||||
|
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
|
||||||
|
|
||||||
|
const konto = await db.kalenderkonto.upsert({
|
||||||
|
where: {
|
||||||
|
benutzerId_anbieter_extKontoId_kalenderId: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter,
|
||||||
|
extKontoId,
|
||||||
|
kalenderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||||
|
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||||
|
tokenGueltigBis: satz.gueltigBis,
|
||||||
|
bereiche: satz.bereiche,
|
||||||
|
aktiv: true,
|
||||||
|
letzterFehler: null,
|
||||||
|
// Neu verbunden heißt: von vorn abgleichen.
|
||||||
|
deltaVerweis: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter,
|
||||||
|
anzeigename:
|
||||||
|
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
|
||||||
|
extKontoId,
|
||||||
|
kalenderId,
|
||||||
|
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||||
|
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||||
|
tokenGueltigBis: satz.gueltigBis,
|
||||||
|
bereiche: satz.bereiche,
|
||||||
|
},
|
||||||
|
select: { id: true, anzeigename: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'kalenderkonto.verbunden',
|
||||||
|
objektTyp: 'Kalenderkonto',
|
||||||
|
objektId: konto.id,
|
||||||
|
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
|
||||||
|
details: { anbieter, anzeigename: konto.anzeigename },
|
||||||
|
})
|
||||||
|
|
||||||
|
const bericht = await kontoAbgleichen(konto.id)
|
||||||
|
|
||||||
|
return zurueck(
|
||||||
|
bericht.ok
|
||||||
|
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
|
||||||
|
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
|
||||||
|
bericht.ok ? 'ok' : 'fehler',
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
|
||||||
|
return zurueck(meldung, 'fehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { autorisierungsAdresse } from '@/server/kalender/oauth'
|
||||||
|
import {
|
||||||
|
alsAnbieter,
|
||||||
|
faehigkeitFuer,
|
||||||
|
istAnbieter,
|
||||||
|
zustandErzeugen,
|
||||||
|
} from '@/server/kalender/verbinden'
|
||||||
|
import { angemeldet } from '@/server/berechtigung'
|
||||||
|
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
|
||||||
|
*
|
||||||
|
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
|
||||||
|
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
|
||||||
|
* die Möglichkeit anderswo gäbe.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ anbieter: string }> },
|
||||||
|
) {
|
||||||
|
const { anbieter: roh } = await params
|
||||||
|
if (!istAnbieter(roh)) notFound()
|
||||||
|
|
||||||
|
const anbieter = alsAnbieter(roh)
|
||||||
|
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||||
|
|
||||||
|
const benutzer = await angemeldet()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zustand = zustandErzeugen(anbieter, benutzer.id)
|
||||||
|
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
|
||||||
|
process.env.APP_URL ?? '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||||
|
import { graphSeiteLesen } from '@/lib/kalender/graph'
|
||||||
|
import { googleSeiteLesen } from '@/lib/kalender/google'
|
||||||
|
import {
|
||||||
|
VerweisAbgelaufen,
|
||||||
|
type AbgleichSeite,
|
||||||
|
type RohTermin,
|
||||||
|
} from '@/lib/kalender/typen'
|
||||||
|
import { tagesBeginn } from '@/lib/zeit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
||||||
|
import { tokenErneuern } from '@/server/kalender/oauth'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich der Kalender.
|
||||||
|
*
|
||||||
|
* Der Ablauf ist bei beiden Anbietern derselbe: Zugriffstoken auffrischen,
|
||||||
|
* Seiten abrufen, bis ein Verweis für die nächste Runde kommt, und die
|
||||||
|
* gelesenen Termine in den örtlichen Spiegel schreiben.
|
||||||
|
*
|
||||||
|
* Beide Anbieter kennen einen Verweis, der abläuft (HTTP 410). Dann wird er
|
||||||
|
* verworfen und von vorn abgeglichen — deshalb ist der Abgleich so gebaut,
|
||||||
|
* dass ein vollständiger Durchlauf jederzeit möglich ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie weit in die Zukunft und Vergangenheit gelesen wird. */
|
||||||
|
export const FENSTER_TAGE_ZURUECK = 7
|
||||||
|
export const FENSTER_TAGE_VORAUS = 90
|
||||||
|
|
||||||
|
/** Höchstzahl Seiten je Runde – schützt vor einer Endlosschleife. */
|
||||||
|
const SEITEN_GRENZE = 50
|
||||||
|
|
||||||
|
export type AbgleichBericht = {
|
||||||
|
kalenderkontoId: string
|
||||||
|
anzeigename: string
|
||||||
|
geaendert: number
|
||||||
|
entfernt: number
|
||||||
|
ok: boolean
|
||||||
|
fehler?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Konto = {
|
||||||
|
id: string
|
||||||
|
anbieter: KalenderAnbieter
|
||||||
|
anzeigename: string
|
||||||
|
kalenderId: string
|
||||||
|
zugriffstoken: string | null
|
||||||
|
aktualisierungstoken: string | null
|
||||||
|
tokenGueltigBis: Date | null
|
||||||
|
deltaVerweis: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitfenster, das abgeglichen wird. */
|
||||||
|
export function abgleichFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||||
|
const heute = tagesBeginn(zeitpunkt)
|
||||||
|
return {
|
||||||
|
von: new Date(heute.getTime() - FENSTER_TAGE_ZURUECK * 86_400_000),
|
||||||
|
bis: new Date(heute.getTime() + FENSTER_TAGE_VORAUS * 86_400_000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt ein gültiges Zugriffstoken zurück und erneuert es, wenn es bald
|
||||||
|
* abläuft. Eine Minute Vorlauf, damit ein Abruf nicht mitten im Ablauf
|
||||||
|
* scheitert.
|
||||||
|
*/
|
||||||
|
async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise<string> {
|
||||||
|
const vorhanden = entschluesseln(konto.zugriffstoken)
|
||||||
|
const laeuftBald =
|
||||||
|
!konto.tokenGueltigBis ||
|
||||||
|
konto.tokenGueltigBis.getTime() - zeitpunkt.getTime() < 60_000
|
||||||
|
|
||||||
|
if (vorhanden && !laeuftBald) return vorhanden
|
||||||
|
|
||||||
|
const aktualisierung = entschluesseln(konto.aktualisierungstoken)
|
||||||
|
if (!aktualisierung) {
|
||||||
|
throw new Error(
|
||||||
|
'Kein Aktualisierungstoken hinterlegt. Das Konto muss neu verbunden werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const satz = await tokenErneuern(konto.anbieter, aktualisierung, zeitpunkt)
|
||||||
|
|
||||||
|
await db.kalenderkonto.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: {
|
||||||
|
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||||
|
...(satz.aktualisierungstoken
|
||||||
|
? { aktualisierungstoken: verschluesseln(satz.aktualisierungstoken) }
|
||||||
|
: {}),
|
||||||
|
tokenGueltigBis: satz.gueltigBis,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return satz.zugriffstoken
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse der ersten Seite einer Runde. */
|
||||||
|
function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string {
|
||||||
|
if (konto.deltaVerweis) return konto.deltaVerweis
|
||||||
|
|
||||||
|
if (konto.anbieter === 'GRAPH') {
|
||||||
|
// "standard" heißt: der Standardkalender des Postfachs.
|
||||||
|
const pfad =
|
||||||
|
konto.kalenderId === 'standard'
|
||||||
|
? '/me/calendarView/delta'
|
||||||
|
: `/me/calendars/${encodeURIComponent(konto.kalenderId)}/calendarView/delta`
|
||||||
|
const parameter = new URLSearchParams({
|
||||||
|
startDateTime: fenster.von.toISOString(),
|
||||||
|
endDateTime: fenster.bis.toISOString(),
|
||||||
|
})
|
||||||
|
return `https://graph.microsoft.com/v1.0${pfad}?${parameter.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const kalender = encodeURIComponent(
|
||||||
|
konto.kalenderId === 'standard' ? 'primary' : konto.kalenderId,
|
||||||
|
)
|
||||||
|
const parameter = new URLSearchParams({
|
||||||
|
timeMin: fenster.von.toISOString(),
|
||||||
|
timeMax: fenster.bis.toISOString(),
|
||||||
|
// Serien werden aufgelöst; uns interessieren die einzelnen Vorkommen.
|
||||||
|
singleEvents: 'true',
|
||||||
|
showDeleted: 'true',
|
||||||
|
maxResults: '250',
|
||||||
|
})
|
||||||
|
return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events?${parameter.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse der nächsten Seite; Google liefert nur einen Token, keine URL. */
|
||||||
|
function naechsteAdresse(konto: Konto, vorher: string, weiter: string): string {
|
||||||
|
if (konto.anbieter === 'GRAPH') return weiter
|
||||||
|
|
||||||
|
const adresse = new URL(vorher)
|
||||||
|
adresse.searchParams.set('pageToken', weiter)
|
||||||
|
return adresse.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verweis für die nächste Runde; Google gibt nur einen Token zurück. */
|
||||||
|
function verweisFuerNaechsteRunde(
|
||||||
|
konto: Konto,
|
||||||
|
vorher: string,
|
||||||
|
deltaVerweis: string,
|
||||||
|
): string {
|
||||||
|
if (konto.anbieter === 'GRAPH') return deltaVerweis
|
||||||
|
|
||||||
|
// Der syncToken verträgt sich nicht mit timeMin/timeMax – die müssen raus.
|
||||||
|
const adresse = new URL(vorher)
|
||||||
|
adresse.searchParams.delete('timeMin')
|
||||||
|
adresse.searchParams.delete('timeMax')
|
||||||
|
adresse.searchParams.delete('pageToken')
|
||||||
|
adresse.searchParams.set('syncToken', deltaVerweis)
|
||||||
|
return adresse.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seiteAbrufen(
|
||||||
|
konto: Konto,
|
||||||
|
adresse: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<AbgleichSeite> {
|
||||||
|
const kopfzeilen: Record<string, string> = { Authorization: `Bearer ${token}` }
|
||||||
|
if (konto.anbieter === 'GRAPH') {
|
||||||
|
// Ohne das liefert Graph Zeiten in der Zeitzone des Postfachs, und der
|
||||||
|
// Text trägt keinen Versatz – die Umrechnung wäre geraten.
|
||||||
|
kopfzeilen.Prefer = 'outlook.timezone="UTC", odata.maxpagesize=100'
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await fetch(adresse, { headers: kopfzeilen })
|
||||||
|
|
||||||
|
if (antwort.status === 410) {
|
||||||
|
throw new VerweisAbgelaufen(konto.anbieter)
|
||||||
|
}
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`Der Anbieter antwortete mit ${antwort.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt: unknown = await antwort.json()
|
||||||
|
return konto.anbieter === 'GRAPH' ? graphSeiteLesen(inhalt) : googleSeiteLesen(inhalt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die gelesenen Termine in den örtlichen Spiegel.
|
||||||
|
*
|
||||||
|
* Googles Folgeabgleiche liefern auch Änderungen außerhalb unseres Fensters,
|
||||||
|
* weil der syncToken die Zeitgrenzen nicht mitträgt. Was daneben liegt, wird
|
||||||
|
* hier verworfen — sonst wüchse der Spiegel unbegrenzt.
|
||||||
|
*/
|
||||||
|
async function spiegeln(
|
||||||
|
kalenderkontoId: string,
|
||||||
|
seite: AbgleichSeite,
|
||||||
|
fenster: { von: Date; bis: Date },
|
||||||
|
): Promise<{ geaendert: number; entfernt: number }> {
|
||||||
|
const imFenster = (termin: RohTermin): boolean =>
|
||||||
|
termin.ende.getTime() > fenster.von.getTime() &&
|
||||||
|
termin.beginn.getTime() < fenster.bis.getTime()
|
||||||
|
|
||||||
|
const zuSchreiben = seite.geaendert.filter(imFenster)
|
||||||
|
const ausserhalb = seite.geaendert.filter((t) => !imFenster(t)).map((t) => t.extId)
|
||||||
|
|
||||||
|
for (const termin of zuSchreiben) {
|
||||||
|
await db.termin.upsert({
|
||||||
|
where: { kalenderkontoId_extId: { kalenderkontoId, extId: termin.extId } },
|
||||||
|
update: {
|
||||||
|
etag: termin.etag,
|
||||||
|
betreff: termin.betreff,
|
||||||
|
beginn: termin.beginn,
|
||||||
|
ende: termin.ende,
|
||||||
|
ganztaegig: termin.ganztaegig,
|
||||||
|
zeigtAls: termin.zeigtAls,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
kalenderkontoId,
|
||||||
|
extId: termin.extId,
|
||||||
|
etag: termin.etag,
|
||||||
|
betreff: termin.betreff,
|
||||||
|
beginn: termin.beginn,
|
||||||
|
ende: termin.ende,
|
||||||
|
ganztaegig: termin.ganztaegig,
|
||||||
|
zeigtAls: termin.zeigtAls,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zuEntfernen = [...seite.entfernt, ...ausserhalb]
|
||||||
|
const entfernt =
|
||||||
|
zuEntfernen.length > 0
|
||||||
|
? (
|
||||||
|
await db.termin.deleteMany({
|
||||||
|
where: { kalenderkontoId, extId: { in: zuEntfernen } },
|
||||||
|
})
|
||||||
|
).count
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return { geaendert: zuSchreiben.length, entfernt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gleicht ein einzelnes Konto ab. */
|
||||||
|
export async function kontoAbgleichen(
|
||||||
|
kalenderkontoId: string,
|
||||||
|
zeitpunkt: Date = jetzt(),
|
||||||
|
): Promise<AbgleichBericht> {
|
||||||
|
const konto = await db.kalenderkonto.findUnique({
|
||||||
|
where: { id: kalenderkontoId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
anbieter: true,
|
||||||
|
anzeigename: true,
|
||||||
|
kalenderId: true,
|
||||||
|
zugriffstoken: true,
|
||||||
|
aktualisierungstoken: true,
|
||||||
|
tokenGueltigBis: true,
|
||||||
|
deltaVerweis: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!konto) {
|
||||||
|
return {
|
||||||
|
kalenderkontoId,
|
||||||
|
anzeigename: '(unbekannt)',
|
||||||
|
geaendert: 0,
|
||||||
|
entfernt: 0,
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Dieses Kalenderkonto gibt es nicht mehr.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fenster = abgleichFenster(zeitpunkt)
|
||||||
|
let geaendert = 0
|
||||||
|
let entfernt = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await gueltigesToken(konto, zeitpunkt)
|
||||||
|
let adresse = ersteAdresse(konto, fenster)
|
||||||
|
let neuerVerweis: string | null = null
|
||||||
|
|
||||||
|
for (let seitenzahl = 0; seitenzahl < SEITEN_GRENZE; seitenzahl++) {
|
||||||
|
let seite: AbgleichSeite
|
||||||
|
try {
|
||||||
|
seite = await seiteAbrufen(konto, adresse, token)
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof VerweisAbgelaufen && konto.deltaVerweis) {
|
||||||
|
// Von vorn: Verweis verwerfen und den vollen Zeitraum neu lesen.
|
||||||
|
await db.kalenderkonto.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: { deltaVerweis: null },
|
||||||
|
})
|
||||||
|
await db.termin.deleteMany({ where: { kalenderkontoId: konto.id } })
|
||||||
|
return kontoAbgleichen(kalenderkontoId, zeitpunkt)
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
const bilanz = await spiegeln(konto.id, seite, fenster)
|
||||||
|
geaendert += bilanz.geaendert
|
||||||
|
entfernt += bilanz.entfernt
|
||||||
|
|
||||||
|
if (seite.deltaVerweis) {
|
||||||
|
neuerVerweis = verweisFuerNaechsteRunde(konto, adresse, seite.deltaVerweis)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!seite.weiter) break
|
||||||
|
adresse = naechsteAdresse(konto, adresse, seite.weiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.kalenderkonto.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: {
|
||||||
|
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
|
||||||
|
letzterAbgleich: zeitpunkt,
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
anzeigename: konto.anzeigename,
|
||||||
|
geaendert,
|
||||||
|
entfernt,
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Unbekannter Fehler beim Abgleich.'
|
||||||
|
|
||||||
|
await db.kalenderkonto.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
anzeigename: konto.anzeigename,
|
||||||
|
geaendert,
|
||||||
|
entfernt,
|
||||||
|
ok: false,
|
||||||
|
fehler: meldung,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gleicht alle aktiven Konten eines Benutzers ab. */
|
||||||
|
export async function alleKontenAbgleichen(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date = jetzt(),
|
||||||
|
): Promise<AbgleichBericht[]> {
|
||||||
|
const konten = await db.kalenderkonto.findMany({
|
||||||
|
where: { benutzerId, aktiv: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const berichte: AbgleichBericht[] = []
|
||||||
|
for (const konto of konten) {
|
||||||
|
berichte.push(await kontoAbgleichen(konto.id, zeitpunkt))
|
||||||
|
}
|
||||||
|
return berichte
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth für die Kalenderanbieter.
|
||||||
|
*
|
||||||
|
* Bewusst mit blankem fetch statt mit einem SDK: es geht um drei Aufrufe je
|
||||||
|
* Anbieter, und die Antworten werden ohnehin mit zod geprüft. Ein SDK würde
|
||||||
|
* hier vor allem Abhängigkeiten und blinde Flecken hinzufügen.
|
||||||
|
*
|
||||||
|
* Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest. Sonst
|
||||||
|
* müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist genau der
|
||||||
|
* Schritt, an dem Einrichtung scheitert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GRAPH_BEREICHE = ['offline_access', 'openid', 'email', 'Calendars.ReadWrite']
|
||||||
|
const GOOGLE_BEREICHE = [
|
||||||
|
'https://www.googleapis.com/auth/calendar.events',
|
||||||
|
'https://www.googleapis.com/auth/calendar.readonly',
|
||||||
|
'openid',
|
||||||
|
'email',
|
||||||
|
]
|
||||||
|
|
||||||
|
export type AnbieterEinstellung = {
|
||||||
|
anbieter: KalenderAnbieter
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
autorisierungsUrl: string
|
||||||
|
tokenUrl: string
|
||||||
|
bereiche: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anbieterEinstellung(anbieter: KalenderAnbieter): AnbieterEinstellung {
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
if (anbieter === 'GRAPH') {
|
||||||
|
if (!einst.graphAktiv) {
|
||||||
|
throw new Error('Microsoft Graph ist nicht eingerichtet (GRAPH_CLIENT_ID fehlt).')
|
||||||
|
}
|
||||||
|
const tenant = einst.GRAPH_TENANT_ID || 'organizations'
|
||||||
|
return {
|
||||||
|
anbieter,
|
||||||
|
clientId: einst.GRAPH_CLIENT_ID,
|
||||||
|
clientSecret: einst.GRAPH_CLIENT_SECRET,
|
||||||
|
autorisierungsUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
||||||
|
tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||||
|
bereiche: GRAPH_BEREICHE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!einst.googleAktiv) {
|
||||||
|
throw new Error('Google ist nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
anbieter,
|
||||||
|
clientId: einst.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: einst.GOOGLE_CLIENT_SECRET,
|
||||||
|
autorisierungsUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||||
|
bereiche: GOOGLE_BEREICHE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Rückleit-URI, den es beim Anbieter zu hinterlegen gilt. */
|
||||||
|
export function rueckleitUrl(anbieter: KalenderAnbieter): string {
|
||||||
|
return `${umgebung().APP_URL}/api/kalender/rueckleitung/${anbieter.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse, zu der der Browser für die Zustimmung geschickt wird. */
|
||||||
|
export function autorisierungsAdresse(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
zustand: string,
|
||||||
|
): string {
|
||||||
|
const einst = anbieterEinstellung(anbieter)
|
||||||
|
const parameter = new URLSearchParams({
|
||||||
|
client_id: einst.clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: rueckleitUrl(anbieter),
|
||||||
|
scope: einst.bereiche.join(' '),
|
||||||
|
state: zustand,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (anbieter === 'GOOGLE') {
|
||||||
|
// Ohne beides gibt Google kein Aktualisierungstoken heraus – und ohne das
|
||||||
|
// wäre die Verbindung nach einer Stunde wieder tot.
|
||||||
|
parameter.set('access_type', 'offline')
|
||||||
|
parameter.set('prompt', 'consent')
|
||||||
|
} else {
|
||||||
|
parameter.set('response_mode', 'query')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${einst.autorisierungsUrl}?${parameter.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenAntwort = z.looseObject({
|
||||||
|
access_token: z.string().min(1),
|
||||||
|
refresh_token: z.string().min(1).optional(),
|
||||||
|
expires_in: z.coerce.number().int().positive().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
id_token: z.string().optional(),
|
||||||
|
token_type: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Tokensatz = {
|
||||||
|
zugriffstoken: string
|
||||||
|
aktualisierungstoken: string | null
|
||||||
|
gueltigBis: Date | null
|
||||||
|
bereiche: string | null
|
||||||
|
/** Aus dem id_token gelesen, wenn vorhanden – dient dem Wiedererkennen. */
|
||||||
|
kennung: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die E-Mail aus einem id_token, ohne die Signatur zu prüfen. */
|
||||||
|
function kennungAus(idToken: string | undefined): string | null {
|
||||||
|
if (!idToken) return null
|
||||||
|
const teile = idToken.split('.')
|
||||||
|
if (teile.length < 2) return null
|
||||||
|
try {
|
||||||
|
const nutzlast = JSON.parse(
|
||||||
|
Buffer.from(teile[1] ?? '', 'base64url').toString('utf8'),
|
||||||
|
) as Record<string, unknown>
|
||||||
|
// Die Signatur wird bewusst nicht geprüft: das Token kommt gerade eben
|
||||||
|
// über eine TLS-Verbindung direkt vom Anbieter, und der Wert dient nur
|
||||||
|
// der Anzeige und dem Wiedererkennen – nie einer Berechtigung.
|
||||||
|
const wert = nutzlast.email ?? nutzlast.preferred_username ?? nutzlast.upn
|
||||||
|
return typeof wert === 'string' ? wert : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenAbrufen(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
koerper: URLSearchParams,
|
||||||
|
jetzt: Date,
|
||||||
|
): Promise<Tokensatz> {
|
||||||
|
const einst = anbieterEinstellung(anbieter)
|
||||||
|
koerper.set('client_id', einst.clientId)
|
||||||
|
koerper.set('client_secret', einst.clientSecret)
|
||||||
|
|
||||||
|
const antwort = await fetch(einst.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: koerper,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
// Der Antwortkörper enthält oft das Client-Secret im Klartext-Echo –
|
||||||
|
// deshalb kommt hier nur der Status heraus.
|
||||||
|
throw new Error(`Der Anbieter hat den Token-Aufruf mit ${antwort.status} abgelehnt.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = tokenAntwort.safeParse(await antwort.json())
|
||||||
|
if (!gelesen.success) {
|
||||||
|
throw new Error('Die Token-Antwort des Anbieters war nicht verwertbar.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
zugriffstoken: gelesen.data.access_token,
|
||||||
|
aktualisierungstoken: gelesen.data.refresh_token ?? null,
|
||||||
|
gueltigBis: gelesen.data.expires_in
|
||||||
|
? new Date(jetzt.getTime() + gelesen.data.expires_in * 1000)
|
||||||
|
: null,
|
||||||
|
bereiche: gelesen.data.scope ?? null,
|
||||||
|
kennung: kennungAus(gelesen.data.id_token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tauscht den Code aus der Rückleitung gegen Tokens. */
|
||||||
|
export async function codeEinloesen(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
code: string,
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): Promise<Tokensatz> {
|
||||||
|
return tokenAbrufen(
|
||||||
|
anbieter,
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: rueckleitUrl(anbieter),
|
||||||
|
}),
|
||||||
|
jetzt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt ein frisches Zugriffstoken. Google schickt dabei kein neues
|
||||||
|
* Aktualisierungstoken mit – das alte bleibt gültig und wird behalten.
|
||||||
|
*/
|
||||||
|
export async function tokenErneuern(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
aktualisierungstoken: string,
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): Promise<Tokensatz> {
|
||||||
|
const satz = await tokenAbrufen(
|
||||||
|
anbieter,
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: aktualisierungstoken,
|
||||||
|
}),
|
||||||
|
jetzt,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...satz,
|
||||||
|
aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
|
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||||
|
import type { Faehigkeit } from '@/lib/instanz'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustandswert für den OAuth-Ablauf.
|
||||||
|
*
|
||||||
|
* Er trägt Anbieter und Benutzer und ist mit AUTH_SECRET signiert. Ohne das
|
||||||
|
* könnte jemand einen Rückleit-Aufruf unterschieben und ein fremdes
|
||||||
|
* Kalenderkonto an das eigene Konto hängen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRENNER = '.'
|
||||||
|
|
||||||
|
export function faehigkeitFuer(anbieter: KalenderAnbieter): Faehigkeit {
|
||||||
|
return anbieter === 'GRAPH' ? 'kalender:graph' : 'kalender:google'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istAnbieter(wert: string): wert is Lowercase<KalenderAnbieter> {
|
||||||
|
return wert === 'graph' || wert === 'google'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alsAnbieter(wert: string): KalenderAnbieter {
|
||||||
|
return wert.toUpperCase() as KalenderAnbieter
|
||||||
|
}
|
||||||
|
|
||||||
|
function signieren(nutzlast: string): string {
|
||||||
|
return createHmac('sha256', umgebung().AUTH_SECRET).update(nutzlast).digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt einen signierten Zustandswert. */
|
||||||
|
export function zustandErzeugen(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
benutzerId: string,
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const nutzlast = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
a: anbieter,
|
||||||
|
b: benutzerId,
|
||||||
|
z: jetzt.getTime(),
|
||||||
|
n: randomBytes(9).toString('base64url'),
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url')
|
||||||
|
|
||||||
|
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Zustand = { anbieter: KalenderAnbieter; benutzerId: string }
|
||||||
|
|
||||||
|
/** Gültigkeit des Zustandswerts – die Zustimmung dauert keine Stunde. */
|
||||||
|
const ZUSTAND_GUELTIG_MINUTEN = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Zustandswert. Gibt null zurück, wenn er nicht stimmt, verändert
|
||||||
|
* wurde oder zu alt ist.
|
||||||
|
*/
|
||||||
|
export function zustandLesen(
|
||||||
|
wert: string | null,
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): Zustand | null {
|
||||||
|
if (!wert) return null
|
||||||
|
|
||||||
|
const [nutzlast, unterschrift] = wert.split(TRENNER)
|
||||||
|
if (!nutzlast || !unterschrift) return null
|
||||||
|
|
||||||
|
const erwartet = Buffer.from(signieren(nutzlast), 'utf8')
|
||||||
|
const erhalten = Buffer.from(unterschrift, 'utf8')
|
||||||
|
if (erwartet.length !== erhalten.length) return null
|
||||||
|
if (!timingSafeEqual(erwartet, erhalten)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inhalt = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as {
|
||||||
|
a?: unknown
|
||||||
|
b?: unknown
|
||||||
|
z?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof inhalt.a !== 'string' || typeof inhalt.b !== 'string') return null
|
||||||
|
if (typeof inhalt.z !== 'number') return null
|
||||||
|
if (jetzt.getTime() - inhalt.z > ZUSTAND_GUELTIG_MINUTEN * 60_000) return null
|
||||||
|
if (inhalt.a !== 'GRAPH' && inhalt.a !== 'GOOGLE') return null
|
||||||
|
|
||||||
|
return { anbieter: inhalt.a, benutzerId: inhalt.b }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,17 @@ const schema = z.object({
|
|||||||
WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'),
|
WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'),
|
||||||
WEBAUTHN_ORIGIN: z.url(),
|
WEBAUTHN_ORIGIN: z.url(),
|
||||||
|
|
||||||
|
// --- Kalenderanbieter (M3) ---
|
||||||
|
// Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte
|
||||||
|
// tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts.
|
||||||
|
GRAPH_CLIENT_ID: z.string().trim().default(''),
|
||||||
|
GRAPH_CLIENT_SECRET: z.string().trim().default(''),
|
||||||
|
/// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||||
|
GRAPH_TENANT_ID: z.string().trim().default('organizations'),
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
||||||
|
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
||||||
|
|
||||||
DEMO_MODUS: jaNein.default(false),
|
DEMO_MODUS: jaNein.default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,6 +48,9 @@ export type Umgebung = z.infer<typeof schema> & {
|
|||||||
instanz: Instanz
|
instanz: Instanz
|
||||||
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
|
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
|
||||||
oidcAktiv: boolean
|
oidcAktiv: boolean
|
||||||
|
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
||||||
|
graphAktiv: boolean
|
||||||
|
googleAktiv: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let zwischengespeichert: Umgebung | null = null
|
let zwischengespeichert: Umgebung | null = null
|
||||||
@@ -56,6 +70,8 @@ function lesen(): Umgebung {
|
|||||||
oidcAktiv: Boolean(
|
oidcAktiv: Boolean(
|
||||||
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET,
|
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET,
|
||||||
),
|
),
|
||||||
|
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
||||||
|
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user