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:
co-authored by
Claude Opus 5
parent
e271f5c1e8
commit
c8eaaa1947
+36
@@ -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} />
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user