feat(auth): Client-Secret in den Einstellungen pflegen, Warnung vor Ablauf

Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand
mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht
vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND
Postfachabgleich still, denn beide haengen an derselben Registrierung.

Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen,
Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber
jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird
es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem
Laden abruft.

Bewusst so entschieden:
  - Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel
    "keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM-
    verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz-
    weise JWT_SECRET, und steht damit nie im selben Dump.
  - Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei
    Microsoft. Ein Tippfehler wird gar nicht erst uebernommen.
  - Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und
    "Hinterlegtes Secret verwerfen" schaltet dorthin zurueck.
  - Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich,
    statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an
    der falschen Stelle.
  - Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte
    Application.Read.All, die die App bewusst nicht hat.

MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den
Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss
jetzt in die Datenbank sehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 08:38:28 +02:00
co-authored by Claude Opus 5
parent e271f5c1e8
commit c8eaaa1947
22 changed files with 937 additions and 64 deletions
+36
View File
@@ -228,6 +228,42 @@ const App: React.FC = () => {
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
/>
{/*
* Hinweis auf das ablaufende Client-Secret. Er steht ueber allen
* Ansichten, weil er niemanden bei der Arbeit unterbricht, aber auch
* nicht zu uebersehen sein soll - ein Toast waere beim Wegklicken
* vergessen. Der Server schickt ihn nur Administratoren und nur ab 30
* Tagen vor dem Ablauf.
*/}
{badges?.ms_secret && (
<button
type="button"
onClick={() => goTo('Einstellungen', { tab: 'sso' })}
className={`flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm font-semibold transition md:px-6 ${
badges.ms_secret.level === 'abgelaufen'
? 'border-red-200 bg-red-50 text-red-800 hover:bg-red-100'
: 'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-100'
}`}
>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<span>
{badges.ms_secret.level === 'abgelaufen'
? 'Das Microsoft-Client-Secret ist abgelaufen - Anmeldung über Microsoft und Postfachabgleich stehen still.'
: `Das Microsoft-Client-Secret läuft in ${badges.ms_secret.days_left} ${
badges.ms_secret.days_left === 1 ? 'Tag' : 'Tagen'
} ab und muss erneuert werden.`}
</span>
<span className="ml-auto shrink-0 whitespace-nowrap underline">Einstellungen öffnen</span>
</button>
)}
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
{currentView === 'Dashboard' && (
<Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} />
+250 -7
View File
@@ -1,15 +1,16 @@
import React, { useEffect, useState } from 'react';
import { ssoApi, type SsoConfig } from '../../lib/api';
import { msSecretApi, ssoApi, type MsSecretStatus, type SsoConfig } from '../../lib/api';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
/*
* Anmeldung über Microsoft Entra - Adminsicht.
*
* Hier lässt sich nichts umstellen, und das ist Absicht: Tenant, Client-ID,
* Secret und Umleitungsadresse stehen in server/.env und tauchen damit weder in
* der Oberfläche noch in einem Datenbank-Dump auf. Diese Seite zeigt nur, ob
* die Anbindung steht, und nennt die Werte, die in Entra eingetragen sein
* müssen.
* Tenant-ID, Client-ID und Umleitungsadresse stehen in server/.env und sind
* hier nur ablesbar. Das Client-Secret ist die Ausnahme: es läuft in Entra ab
* und muss dann erneuert werden, ohne dass jemand auf den Server muss. Es geht
* deshalb hier hinein - verschlüsselt in die Datenbank - kommt aber nie wieder
* heraus. Angezeigt wird nur, woher es stammt und wie lange es noch gilt.
*/
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
@@ -19,6 +20,220 @@ const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, valu
</div>
);
/** Datum als 21.09.2026; leere Eingabe bleibt leer. */
function formatDate(value: string | null): string {
if (!value) return '—';
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Farbe und Text zum Ablaufzustand - einmal definiert, dreimal gebraucht. */
function expiryTone(secret: MsSecretStatus): { accent: string; text: string; note: string } {
if (secret.level === 'abgelaufen') {
return {
accent: 'border-l-red-500',
text: 'text-red-700',
note: 'Das Client-Secret ist abgelaufen. Anmeldung über Microsoft und Postfachabgleich stehen still, bis ein neues eingetragen ist.',
};
}
if (secret.level === 'warnung') {
return {
accent: 'border-l-amber-500',
text: 'text-amber-700',
note: `Das Client-Secret läuft in ${secret.days_left} ${
secret.days_left === 1 ? 'Tag' : 'Tagen'
} ab. In Entra ein neues erzeugen und hier eintragen.`,
};
}
if (secret.level === 'unbekannt') {
return {
accent: 'border-l-ink-300',
text: 'text-ink-500',
note: 'Kein Ablaufdatum hinterlegt. Trage es ein, dann warnt das CRM rechtzeitig.',
};
}
return { accent: 'border-l-green-500', text: 'text-green-700', note: '' };
}
const SecretSection: React.FC<{ secret: MsSecretStatus; onChange: (next: MsSecretStatus) => void }> = ({
secret,
onChange,
}) => {
const { addToast } = useToast();
const [value, setValue] = useState('');
const [expiresAt, setExpiresAt] = useState(secret.expires_at ?? '');
const [isSaving, setIsSaving] = useState(false);
const [isClearing, setIsClearing] = useState(false);
// Nach dem Speichern kommt der neue Zustand von aussen - das Datumsfeld soll
// ihm folgen, damit es nicht den alten Wert weiterzeigt.
useEffect(() => {
setExpiresAt(secret.expires_at ?? '');
}, [secret.expires_at]);
const tone = expiryTone(secret);
const expiryChanged = (secret.expires_at ?? '') !== expiresAt;
const canSave = Boolean(value.trim()) || expiryChanged;
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!canSave) return;
setIsSaving(true);
try {
const payload: { secret?: string; expires_at?: string } = {};
if (value.trim()) payload.secret = value.trim();
if (expiryChanged) payload.expires_at = expiresAt;
const result = await msSecretApi.save(payload);
onChange(result.secret);
setValue('');
addToast(
result.verified
? 'Secret gespeichert - Microsoft hat es akzeptiert.'
: 'Ablaufdatum gespeichert.',
'success'
);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const handleClear = async () => {
setIsClearing(true);
try {
const result = await msSecretApi.clear();
onChange(result.secret);
addToast('Hinterlegtes Secret verworfen - es gilt wieder der Wert aus server/.env.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error');
} finally {
setIsClearing(false);
}
};
return (
<section className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${tone.accent}`}>
<h3 className="text-base text-ink-900">Client-Secret</h3>
<p className="mt-1 text-xs text-ink-400">
Gilt für Anmeldung <em>und</em> Postfachanbindung - es ist dieselbe App-Registrierung. Das
Secret wird verschlüsselt gespeichert und nie wieder angezeigt.
</p>
<dl className="mt-4 space-y-2">
<Fact
label="Quelle"
value={
secret.source === 'db'
? 'hier hinterlegt'
: secret.source === 'env'
? 'server/.env'
: secret.source === 'db-unlesbar'
? 'hinterlegt, aber nicht entschlüsselbar'
: 'fehlt'
}
/>
<Fact label="Läuft ab am" value={formatDate(secret.expires_at)} />
<Fact
label="Verbleibend"
value={
secret.days_left === null ? (
'—'
) : (
<span className={tone.text}>
{secret.days_left < 0
? `seit ${Math.abs(secret.days_left)} Tagen abgelaufen`
: `${secret.days_left} ${secret.days_left === 1 ? 'Tag' : 'Tage'}`}
</span>
)
}
/>
<Fact
label="Zuletzt geändert"
value={secret.saved_at ? new Date(secret.saved_at).toLocaleString('de-DE') : '—'}
/>
</dl>
{tone.note && (
<p className={`mt-4 rounded-xl border border-current/20 bg-current/5 px-4 py-3 text-sm font-semibold ${tone.text}`}>
{tone.note}
</p>
)}
{secret.source === 'db-unlesbar' && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
Das gespeicherte Secret lässt sich nicht entschlüsseln. Das passiert, wenn
<code className="mx-1">JWT_SECRET</code> bzw. <code className="mx-1">CRM_ENCRYPTION_KEY</code>
gewechselt wurde. Einfach unten neu eintragen.
</p>
)}
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<div>
<label className={labelClass} htmlFor="ms-secret">
Neues Secret
</label>
<input
id="ms-secret"
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Wert aus Entra → Zertifikate & Geheimnisse"
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Leer lassen, wenn nur das Ablaufdatum geändert werden soll. Beim Speichern holt das CRM
testweise ein Token - ein vertipptes Secret wird gar nicht erst übernommen.
</p>
</div>
<div>
<label className={labelClass} htmlFor="ms-secret-expires">
Ablaufdatum
</label>
<input
id="ms-secret-expires"
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Steht in Entra neben dem Geheimnis. Ab {secret.warn_days} Tagen vorher weist das CRM alle
Administratoren darauf hin.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="submit"
disabled={!canSave || isSaving}
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{isSaving ? 'Speichere...' : 'Speichern'}
</button>
{secret.source === 'db' || secret.source === 'db-unlesbar' ? (
<button
type="button"
onClick={handleClear}
disabled={isClearing}
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-700 disabled:opacity-50"
>
{isClearing ? 'Verwerfe...' : 'Hinterlegtes Secret verwerfen'}
</button>
) : null}
</div>
</form>
</section>
);
};
export const SsoSettings: React.FC = () => {
const { addToast } = useToast();
const [config, setConfig] = useState<SsoConfig | null>(null);
@@ -75,12 +290,18 @@ export const SsoSettings: React.FC = () => {
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Fehlt in <code>server/.env</code>: {config.missing.join(', ')}
<span className="mt-1 block text-xs font-normal">
Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten.
Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten. Das
Client-Secret ist die Ausnahme: das lässt sich hier eintragen.
</span>
</div>
)}
</section>
<SecretSection
secret={config.secret}
onChange={(secret) => setConfig((prev) => (prev ? { ...prev, secret } : prev))}
/>
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Wie es funktioniert</h3>
<ul className="mt-4 space-y-3 text-sm text-ink-600">
@@ -105,6 +326,28 @@ export const SsoSettings: React.FC = () => {
</li>
</ul>
</section>
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Secret in Entra erneuern</h3>
<ol className="mt-4 space-y-3 text-sm text-ink-600">
<li>
<span className="font-bold text-ink-800">1.</span> Entra → App-Registrierungen → perinco
crm → <em>Zertifikate &amp; Geheimnisse</em> → <em>Neuer geheimer Clientschlüssel</em>.
</li>
<li>
<span className="font-bold text-ink-800">2.</span> Wert sofort kopieren - Entra zeigt ihn
nur einmal an.
</li>
<li>
<span className="font-bold text-ink-800">3.</span> Hier eintragen, Ablaufdatum aus Entra
übernehmen, speichern. Das CRM prüft das Secret sofort bei Microsoft.
</li>
<li>
<span className="font-bold text-ink-800">4.</span> Erst danach das alte Geheimnis in
Entra löschen.
</li>
</ol>
</section>
</div>
);
};
+33
View File
@@ -119,11 +119,24 @@ export const authApi = {
*/
export const SSO_START_URL = '/api/auth/sso/start';
/** Zustand des Client-Secrets - ohne das Secret selbst. */
export interface MsSecretStatus {
configured: boolean;
/** 'db' = in den Einstellungen hinterlegt, 'env' = aus server/.env */
source: 'db' | 'env' | 'db-unlesbar' | null;
expires_at: string | null;
saved_at: string | null;
days_left: number | null;
level: 'ok' | 'warnung' | 'abgelaufen' | 'unbekannt';
warn_days: number;
}
export interface SsoConfig {
enabled: boolean;
missing: string[];
redirect_uri: string | null;
allowed_domains: string[];
secret: MsSecretStatus;
}
export const ssoApi = {
@@ -138,6 +151,21 @@ export const ssoApi = {
}),
};
/**
* Client-Secret der Microsoft-App-Registrierung (nur admin). Es geht nur
* hinein, nie heraus - gelesen wird ausschliesslich der Zustand.
*/
export const msSecretApi = {
save: (input: { secret?: string; expires_at?: string }) =>
request<{ ok: true; verified: boolean; secret: MsSecretStatus }>('/auth/microsoft-secret', {
method: 'PUT',
body: JSON.stringify(input),
}),
/** Verwirft das hinterlegte Secret; ab dann gilt wieder server/.env. */
clear: () =>
request<{ ok: true; secret: MsSecretStatus }>('/auth/microsoft-secret', { method: 'DELETE' }),
};
// ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput {
@@ -312,6 +340,11 @@ export interface SidebarBadges {
opportunity_trash: number;
mail_trash: number;
scanner_rejected: number | null;
/**
* Nur fuer Administratoren und nur, wenn das Client-Secret bald ablaeuft oder
* schon abgelaufen ist - sonst null.
*/
ms_secret: { level: 'warnung' | 'abgelaufen'; days_left: number | null; expires_at: string | null } | null;
}
export const badgesApi = {