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
@@ -66,6 +66,7 @@ mitten im Scrapen beenden.
|
|||||||
| `MS_SSO_ENABLED` | Anmeldung über Microsoft, Standard aus, nur `true` schaltet ein |
|
| `MS_SSO_ENABLED` | Anmeldung über Microsoft, Standard aus, nur `true` schaltet ein |
|
||||||
| `MS_SSO_REDIRECT_URI` | Umleitungsadresse, zeichengenau wie in Entra eingetragen |
|
| `MS_SSO_REDIRECT_URI` | Umleitungsadresse, zeichengenau wie in Entra eingetragen |
|
||||||
| `MS_SSO_ALLOWED_DOMAINS` | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung |
|
| `MS_SSO_ALLOWED_DOMAINS` | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung |
|
||||||
|
| `CRM_ENCRYPTION_KEY` | verschlüsselt das in der Oberfläche hinterlegte Client-Secret; fehlt er, dient `JWT_SECRET` als Schlüssel |
|
||||||
|
|
||||||
Die Scanner-Datenbank wird beim Start automatisch angelegt
|
Die Scanner-Datenbank wird beim Start automatisch angelegt
|
||||||
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
|
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
|
||||||
@@ -84,6 +85,11 @@ Die `MS_SSO_*`-Werte gehören zur Anmeldung über Microsoft und nutzen dieselbe
|
|||||||
App-Registrierung wie das Postfach. Ohne sie bleibt es bei Benutzername und
|
App-Registrierung wie das Postfach. Ohne sie bleibt es bei Benutzername und
|
||||||
Passwort. Einrichtung: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
|
Passwort. Einrichtung: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
|
||||||
|
|
||||||
|
`MS_CLIENT_SECRET` ist seit dem 21.09.2026 nur noch die Rückfallebene: Vorrang
|
||||||
|
hat das Secret, das Administratoren unter *Einstellungen → Anmeldung* eintragen.
|
||||||
|
Es liegt verschlüsselt in `crm_settings`, wird bei jedem Bedarf frisch gelesen
|
||||||
|
und braucht deshalb **keinen** Neustart.
|
||||||
|
|
||||||
## Playwright
|
## Playwright
|
||||||
|
|
||||||
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
|
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen
|
|||||||
welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
|
welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
|
||||||
Benutzernamen durchprobieren.
|
Benutzernamen durchprobieren.
|
||||||
|
|
||||||
|
Erscheint über der Ansicht ein gelbes oder rotes Band zum *Microsoft-Client-Secret*,
|
||||||
|
siehst du es als Administrator: der Zugangsschlüssel zu Microsoft läuft ab. Ein
|
||||||
|
Klick darauf führt nach *Einstellungen → Anmeldung*, dort steht Schritt für
|
||||||
|
Schritt, wie er erneuert wird. Läuft er aus, stehen Microsoft-Anmeldung und
|
||||||
|
Postfachabgleich still.
|
||||||
|
|
||||||
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung,
|
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung,
|
||||||
die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -101,7 +101,7 @@ Alle unter `router.use(requireAuth)`.
|
|||||||
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
|
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
|
||||||
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete |
|
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete |
|
||||||
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
|
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
|
||||||
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
|
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft |
|
||||||
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
|
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
|
||||||
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
|
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
|
||||||
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
|
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
|
||||||
@@ -200,6 +200,36 @@ Eigenheiten, die man kennen muss:
|
|||||||
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
|
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
|
||||||
kommt sofort mit `skipped` zurück, statt parallel zu laufen.
|
kommt sofort mit `skipped` zurück, statt parallel zu laufen.
|
||||||
|
|
||||||
|
### `server/src/msSecret.js`
|
||||||
|
|
||||||
|
Das Client-Secret der Microsoft-App-Registrierung, wenn es nicht aus der `.env`
|
||||||
|
kommt, sondern in der Oberfläche hinterlegt wurde.
|
||||||
|
|
||||||
|
| Export | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `effectiveSecret()` / `requireSecret()` | gültiges Secret, Datenbank vor `.env` |
|
||||||
|
| `hasSecret()` | für die Konfigurationsprüfung von Postfach und Anmeldung |
|
||||||
|
| `secretStatus()` | Quelle, Ablaufdatum, Resttage, Warnstufe - ohne das Secret |
|
||||||
|
| `saveSecret()` / `clearStoredSecret()` | schreiben und zurücknehmen |
|
||||||
|
| `verifySecret()` | Probe bei Microsoft, bevor gespeichert wird |
|
||||||
|
| `daysUntil()`, `expiryLevel()`, `normalizeExpiry()` | Ablaufrechnung, in `test/msSecret.test.mjs` geprüft |
|
||||||
|
|
||||||
|
Eigenheiten, die man kennen muss:
|
||||||
|
|
||||||
|
* Verschlüsselt wird mit AES-256-GCM, Schlüssel aus `CRM_ENCRYPTION_KEY` oder
|
||||||
|
ersatzweise `JWT_SECRET` (`scrypt`). Jeder Aufruf bekommt einen eigenen
|
||||||
|
Zufallsvektor, das Format trägt das Präfix `v1:`.
|
||||||
|
* Passt der Schlüssel nicht mehr, ist die Quelle `db-unlesbar` - und es wird
|
||||||
|
**nicht** still auf die `.env` zurückgefallen.
|
||||||
|
* Ist die Datenbank nicht erreichbar, gilt das dagegen als „nichts hinterlegt";
|
||||||
|
dann greift die `.env`, damit der Postfachabgleich keine irreführende Meldung
|
||||||
|
liefert.
|
||||||
|
* Kein Zwischenspeicher: eine Änderung wirkt sofort, ohne Neustart. Das
|
||||||
|
Graph-Token wird beim Speichern verworfen, es gehört zum alten Secret.
|
||||||
|
* `MAIL_CONFIGURED` und `SSO_CONFIGURED` waren Konstanten und sind seitdem die
|
||||||
|
Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die
|
||||||
|
Datenbank sehen.
|
||||||
|
|
||||||
### `server/src/sso/`
|
### `server/src/sso/`
|
||||||
|
|
||||||
Anmeldung über Microsoft Entra, fachlich in
|
Anmeldung über Microsoft Entra, fachlich in
|
||||||
|
|||||||
+4
-1
@@ -59,7 +59,10 @@ Login.
|
|||||||
1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur
|
1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur
|
||||||
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
|
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
|
||||||
Verzeichnis-ID notieren.
|
Verzeichnis-ID notieren.
|
||||||
2. **Client-Geheimnis** anlegen und den Wert sofort kopieren.
|
2. **Client-Geheimnis** anlegen und den Wert sofort kopieren. Es ist dasselbe
|
||||||
|
Geheimnis wie bei der Anmeldung; erneuert wird es seit dem 21.09.2026 unter
|
||||||
|
*Einstellungen → Anmeldung*, siehe
|
||||||
|
[13 Anmeldung über Microsoft](13-anmeldung-sso.md#client-secret-pflegen).
|
||||||
3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* →
|
3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* →
|
||||||
`Mail.Read`, danach Administratorzustimmung erteilen.
|
`Mail.Read`, danach Administratorzustimmung erteilen.
|
||||||
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
|
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
|
||||||
|
|||||||
@@ -100,11 +100,78 @@ antwortet dann mit 404, das ist eingeplant).
|
|||||||
| `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra |
|
| `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra |
|
||||||
| `GET` | `/api/auth/sso/callback` | offen | Rückleitung von Entra, endet in `/?sso_ticket=...` |
|
| `GET` | `/api/auth/sso/callback` | offen | Rückleitung von Entra, endet in `/?sso_ticket=...` |
|
||||||
| `POST` | `/api/auth/sso/ticket` | offen | Ticket gegen CRM-Token tauschen |
|
| `POST` | `/api/auth/sso/ticket` | offen | Ticket gegen CRM-Token tauschen |
|
||||||
| `GET` | `/api/auth/sso/config` | admin | Status, fehlende Variablen, Umleitungsadresse |
|
| `GET` | `/api/auth/sso/config` | admin | Status, fehlende Variablen, Umleitungsadresse, Zustand des Secrets |
|
||||||
|
| `PUT` | `/api/auth/microsoft-secret` | admin | Secret und/oder Ablaufdatum setzen |
|
||||||
|
| `DELETE` | `/api/auth/microsoft-secret` | admin | hinterlegtes Secret verwerfen, `.env` gilt wieder |
|
||||||
|
|
||||||
Scheitert etwas, landet der Browser auf `/?sso_error=<Meldung>`; die
|
Scheitert etwas, landet der Browser auf `/?sso_error=<Meldung>`; die
|
||||||
Anmeldemaske zeigt den Text im gewohnten roten Kasten.
|
Anmeldemaske zeigt den Text im gewohnten roten Kasten.
|
||||||
|
|
||||||
|
## Client-Secret pflegen
|
||||||
|
|
||||||
|
Das Secret der App-Registrierung läuft ab - in Entra sind 6 oder 24 Monate
|
||||||
|
üblich. Läuft es aus, stehen **Anmeldung und Postfachabgleich gleichzeitig**
|
||||||
|
still, denn beide hängen an derselben Registrierung. Damit dafür niemand auf den
|
||||||
|
Server muss, lässt es sich unter *Einstellungen → Anmeldung* eintragen.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Einstellungen →<br/>Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:<br/>Client-Credentials-Token"]
|
||||||
|
B -->|abgelehnt| C["nichts gespeichert,<br/>Meldung von Entra"]
|
||||||
|
B -->|angenommen| D["AES-256-GCM<br/>verschlüsseln"]
|
||||||
|
D --> E[("crm_settings")]
|
||||||
|
E --> F["Graph-Token verwerfen -<br/>der Zwischenspeicher<br/>gehört zum alten Secret"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reihenfolge beim Lesen:** erst `crm_settings`, dann `MS_CLIENT_SECRET` aus der
|
||||||
|
`.env`. Wer nichts einträgt, merkt von der Neuerung nichts; der `.env`-Wert
|
||||||
|
bleibt die Rückfallebene. *Hinterlegtes Secret verwerfen* schaltet bewusst
|
||||||
|
dorthin zurück.
|
||||||
|
|
||||||
|
**Warum verschlüsselt:** ein Geheimnis im Klartext in der Datenbank stünde in
|
||||||
|
jedem Dump. Der Schlüssel kommt aus `CRM_ENCRYPTION_KEY`, ersatzweise aus
|
||||||
|
`JWT_SECRET` - beide stehen in der `.env` und damit nie im selben Dump. Wird
|
||||||
|
eine dieser Variablen gewechselt, lässt sich das gespeicherte Secret nicht mehr
|
||||||
|
lesen; die Oberfläche sagt das dann ausdrücklich, und es wird einmal neu
|
||||||
|
eingetragen. Ein stiller Rückfall auf die `.env` findet in diesem Fall **nicht**
|
||||||
|
statt - sonst sucht man den Fehler an der falschen Stelle.
|
||||||
|
|
||||||
|
**Probe vor dem Speichern:** das CRM holt mit dem eingegebenen Wert ein
|
||||||
|
Client-Credentials-Token. Erst wenn Microsoft es ausstellt, wird gespeichert. Ein
|
||||||
|
Tippfehler legt so nichts lahm.
|
||||||
|
|
||||||
|
### Warnung vor dem Ablauf
|
||||||
|
|
||||||
|
Das Ablaufdatum wird von Hand eingetragen - es aus Entra zu lesen bräuchte die
|
||||||
|
Berechtigung `Application.Read.All`, die die App bewusst nicht hat.
|
||||||
|
|
||||||
|
Ab **30 Tagen** vor dem Ablauf (`WARN_DAYS` in `msSecret.js`) erscheint für
|
||||||
|
Administratoren über jeder Ansicht ein Hinweisband, das direkt in diesen
|
||||||
|
Einstellungsreiter führt; nach dem Ablauf wird es rot. Der Hinweis reist mit
|
||||||
|
`GET /api/badges` mit, das die App ohnehin bei jedem Laden abruft - ein eigener
|
||||||
|
Endpunkt wäre eine zweite Anfrage für eine Zahl. Benutzer ohne Adminrolle
|
||||||
|
bekommen ihn nicht: sie können das Secret nicht erneuern.
|
||||||
|
|
||||||
|
| Zustand | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `ok` | mehr als 30 Tage übrig |
|
||||||
|
| `warnung` | 0 bis 30 Tage übrig - Hinweisband in Gelb |
|
||||||
|
| `abgelaufen` | Datum liegt in der Vergangenheit - Hinweisband in Rot |
|
||||||
|
| `unbekannt` | kein Datum hinterlegt, keine Warnung möglich |
|
||||||
|
|
||||||
|
### So wird erneuert
|
||||||
|
|
||||||
|
1. Entra → *App-Registrierungen* → **perinco crm** → *Zertifikate & Geheimnisse*
|
||||||
|
→ **Neuer geheimer Clientschlüssel**.
|
||||||
|
2. Wert sofort kopieren - Entra zeigt ihn nur einmal.
|
||||||
|
3. Im CRM unter *Einstellungen → Anmeldung* eintragen, Ablaufdatum übernehmen,
|
||||||
|
speichern.
|
||||||
|
4. Erst danach das alte Geheimnis in Entra löschen.
|
||||||
|
|
||||||
|
Ein Neustart des Backends ist nicht nötig: das Secret wird bei jedem Bedarf
|
||||||
|
frisch gelesen, und das zwischengespeicherte Graph-Token wird beim Speichern
|
||||||
|
verworfen.
|
||||||
|
|
||||||
## Einrichtung in Entra
|
## Einrichtung in Entra
|
||||||
|
|
||||||
Die App-Registrierung **perinco crm** existiert bereits für die
|
Die App-Registrierung **perinco crm** existiert bereits für die
|
||||||
@@ -147,7 +214,9 @@ vertraulicher Client mit Secret.
|
|||||||
| `MS_SSO_ENABLED` | ja | nur `true` schaltet die Anmeldung ein |
|
| `MS_SSO_ENABLED` | ja | nur `true` schaltet die Anmeldung ein |
|
||||||
| `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse |
|
| `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse |
|
||||||
| `MS_SSO_ALLOWED_DOMAINS` | nein | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung. Im Betrieb: `perinco.de,icon-experts.de` |
|
| `MS_SSO_ALLOWED_DOMAINS` | nein | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung. Im Betrieb: `perinco.de,icon-experts.de` |
|
||||||
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | ja | dieselben Werte wie beim Postfach |
|
| `MS_TENANT_ID`, `MS_CLIENT_ID` | ja | dieselben Werte wie beim Postfach |
|
||||||
|
| `MS_CLIENT_SECRET` | nein | Rückfallebene; Vorrang hat das in den Einstellungen hinterlegte Secret |
|
||||||
|
| `CRM_ENCRYPTION_KEY` | nein | verschlüsselt das hinterlegte Secret; ohne den Wert dient `JWT_SECRET` als Schlüssel |
|
||||||
|
|
||||||
Nicht jedes CRM-Konto trägt eine `@perinco.de`-Adresse - `icon-experts.de`
|
Nicht jedes CRM-Konto trägt eine `@perinco.de`-Adresse - `icon-experts.de`
|
||||||
gehört dazu. Wer eine Domain vergisst, sperrt die betroffenen Leute vom
|
gehört dazu. Wer eine Domain vergisst, sperrt die betroffenen Leute vom
|
||||||
@@ -182,6 +251,9 @@ Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin.
|
|||||||
| „Die Anmeldung ist abgelaufen" | älter als 10 Minuten, oder das Backend wurde zwischendurch neu gestartet (der offene Vorgang liegt im Prozessspeicher) |
|
| „Die Anmeldung ist abgelaufen" | älter als 10 Minuten, oder das Backend wurde zwischendurch neu gestartet (der offene Vorgang liegt im Prozessspeicher) |
|
||||||
| „Anmeldung kommt aus einem fremden Verzeichnis" | Antwort gehört zu einem anderen Tenant - `MS_TENANT_ID` prüfen |
|
| „Anmeldung kommt aus einem fremden Verzeichnis" | Antwort gehört zu einem anderen Tenant - `MS_TENANT_ID` prüfen |
|
||||||
| Profilfoto fehlt | kein Foto in M365, Bild grösser als 200 KB, oder `ProfilePhoto.Read.All` ohne Zustimmung |
|
| Profilfoto fehlt | kein Foto in M365, Bild grösser als 200 KB, oder `ProfilePhoto.Read.All` ohne Zustimmung |
|
||||||
|
| „Microsoft hat dieses Secret abgelehnt" | Wert falsch kopiert oder das Geheimnis in Entra bereits gelöscht - es wurde nichts gespeichert |
|
||||||
|
| „nicht entschlüsselbar" im Reiter *Anmeldung* | `JWT_SECRET` oder `CRM_ENCRYPTION_KEY` wurde gewechselt; Secret einmal neu eintragen |
|
||||||
|
| Hinweisband erscheint nicht | kein Ablaufdatum hinterlegt, oder das Konto ist kein Administrator |
|
||||||
|
|
||||||
## Quellen im Code
|
## Quellen im Code
|
||||||
|
|
||||||
@@ -193,5 +265,7 @@ Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin.
|
|||||||
| `server/src/schema.sql` | Spalte `crm_users.entra_oid` samt Unique-Index |
|
| `server/src/schema.sql` | Spalte `crm_users.entra_oid` samt Unique-Index |
|
||||||
| `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` |
|
| `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` |
|
||||||
| `src/components/LoginScreen.tsx` | Knopf „Mit Microsoft anmelden" |
|
| `src/components/LoginScreen.tsx` | Knopf „Mit Microsoft anmelden" |
|
||||||
| `src/components/settings/SsoSettings.tsx` | Reiter *Anmeldung* in den Einstellungen |
|
| `src/components/settings/SsoSettings.tsx` | Reiter *Anmeldung* samt Pflege des Secrets |
|
||||||
|
| `server/src/msSecret.js` | Secret verschlüsseln, Ablauf berechnen, bei Microsoft prüfen |
|
||||||
|
| `src/App.tsx` | Hinweisband für Administratoren vor dem Ablauf |
|
||||||
| `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |
|
| `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |
|
||||||
|
|||||||
@@ -121,3 +121,12 @@ MS_SSO_REDIRECT_URI=https://crm.perinco.de/api/auth/sso/callback
|
|||||||
# zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
|
# zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
|
||||||
# icon-experts.de gehoert dazu - Konten dieser Domain arbeiten mit im CRM.
|
# icon-experts.de gehoert dazu - Konten dieser Domain arbeiten mit im CRM.
|
||||||
MS_SSO_ALLOWED_DOMAINS=perinco.de,icon-experts.de
|
MS_SSO_ALLOWED_DOMAINS=perinco.de,icon-experts.de
|
||||||
|
|
||||||
|
# Verschluesselt das Client-Secret, wenn es in der Oberflaeche hinterlegt wird
|
||||||
|
# (Einstellungen > Anmeldung). Fehlt der Wert, dient JWT_SECRET als Schluessel.
|
||||||
|
# Wird einer von beiden gewechselt, muss das Secret einmal neu eingetragen
|
||||||
|
# werden - die Oberflaeche weist darauf hin.
|
||||||
|
# CRM_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# MS_CLIENT_SECRET oben ist seither nur noch die Rueckfallebene: Vorrang hat der
|
||||||
|
# in den Einstellungen hinterlegte Wert.
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"migrate": "node src/migrate.js",
|
"migrate": "node src/migrate.js",
|
||||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs"
|
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSettings, asBool, asInt } from '../settings.js';
|
import { getSettings, asBool, asInt } from '../settings.js';
|
||||||
|
import { hasSecret, requireSecret } from '../msSecret.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Konfiguration der M365-Anbindung.
|
* Konfiguration der M365-Anbindung.
|
||||||
@@ -14,7 +15,6 @@ import { getSettings, asBool, asInt } from '../settings.js';
|
|||||||
|
|
||||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
|
||||||
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
|
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
|
||||||
|
|
||||||
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
|
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
|
||||||
@@ -23,23 +23,27 @@ export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim();
|
|||||||
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
|
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
|
||||||
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
|
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
|
||||||
|
|
||||||
/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */
|
/*
|
||||||
export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX);
|
* Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. Die Pruefung ist
|
||||||
|
* asynchron, seit das Secret auch in der Datenbank liegen darf (siehe
|
||||||
export function clientSecret() {
|
* msSecret.js) - vorher war es eine Konstante aus der .env.
|
||||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
*/
|
||||||
return CLIENT_SECRET;
|
export async function mailConfigured() {
|
||||||
|
return Boolean(TENANT_ID && CLIENT_ID && MAILBOX && (await hasSecret()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das gueltige Client-Secret: Datenbank vor .env. */
|
||||||
|
export const clientSecret = requireSecret;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
|
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
|
||||||
* ohne Werte zu verraten.
|
* ohne Werte zu verraten.
|
||||||
*/
|
*/
|
||||||
export function missingCredentials() {
|
export async function missingCredentials() {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
if (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
|
||||||
if (!MAILBOX) missing.push('MS_MAILBOX');
|
if (!MAILBOX) missing.push('MS_MAILBOX');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|||||||
async function fetchAccessToken() {
|
async function fetchAccessToken() {
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
client_secret: clientSecret(),
|
client_secret: await clientSecret(),
|
||||||
scope: 'https://graph.microsoft.com/.default',
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
grant_type: 'client_credentials',
|
grant_type: 'client_credentials',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js';
|
import { SCHEDULER_ENABLED, mailConfigured, mailSettings } from './config.js';
|
||||||
import { getSyncState, saveSyncState } from './store.js';
|
import { getSyncState, saveSyncState } from './store.js';
|
||||||
import { isMailSyncRunning, runMailSync } from './sync.js';
|
import { isMailSyncRunning, runMailSync } from './sync.js';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function isMailSchedulerRunning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startMailScheduler() {
|
export async function startMailScheduler() {
|
||||||
if (!MAIL_CONFIGURED) {
|
if (!(await mailConfigured())) {
|
||||||
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
|
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
MAILBOX,
|
MAILBOX,
|
||||||
MAIL_FOLDER,
|
MAIL_FOLDER,
|
||||||
MAIL_CONFIGURED,
|
mailConfigured,
|
||||||
MAX_PAGES_PER_RUN,
|
MAX_PAGES_PER_RUN,
|
||||||
MAX_BODY_CHARS,
|
MAX_BODY_CHARS,
|
||||||
mailSettings,
|
mailSettings,
|
||||||
@@ -156,8 +156,8 @@ async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) {
|
|||||||
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
|
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
|
||||||
*/
|
*/
|
||||||
export async function runMailSync({ trigger = 'manuell' } = {}) {
|
export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||||
if (!MAIL_CONFIGURED) {
|
if (!(await mailConfigured())) {
|
||||||
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() };
|
return { skipped: 'nicht-konfiguriert', missing: await missingCredentials() };
|
||||||
}
|
}
|
||||||
if (running) return { skipped: 'laeuft-bereits' };
|
if (running) return { skipped: 'laeuft-bereits' };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { getSettings, setSetting } from './settings.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das Client-Secret der Microsoft-App-Registrierung - hinterlegbar in der
|
||||||
|
* Oberflaeche, mit Ablaufdatum.
|
||||||
|
*
|
||||||
|
* Warum ueberhaupt in der Datenbank: das Secret laeuft in Entra ab (Standard 6
|
||||||
|
* oder 24 Monate). Bisher hiess das: jemand mit SSH-Zugang muss die .env
|
||||||
|
* aendern und den Dienst neu starten - und niemand sieht vorher, wann es soweit
|
||||||
|
* ist. Beides erledigen jetzt Administratoren in den Einstellungen.
|
||||||
|
*
|
||||||
|
* Damit ein Datenbank-Dump trotzdem nichts hergibt, liegt der Wert
|
||||||
|
* AES-256-GCM-verschluesselt dort. Der Schluessel steht in der .env und kommt
|
||||||
|
* nie in die Datenbank - Dump und Schluessel sind also getrennt.
|
||||||
|
*
|
||||||
|
* Reihenfolge beim Lesen: erst die Datenbank, dann die .env. Der .env-Wert
|
||||||
|
* bleibt damit die Rueckfallebene, und wer nichts umstellt, merkt von der
|
||||||
|
* Neuerung nichts.
|
||||||
|
*
|
||||||
|
* Dasselbe Secret bedient Anmeldung UND Postfachanbindung - es ist dieselbe
|
||||||
|
* App-Registrierung. Eine Aenderung wirkt also auf beides.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY_CIPHER = 'ms_client_secret';
|
||||||
|
const KEY_EXPIRES = 'ms_client_secret_expires_at';
|
||||||
|
const KEY_SAVED = 'ms_client_secret_saved_at';
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
[KEY_CIPHER]: '',
|
||||||
|
[KEY_EXPIRES]: '',
|
||||||
|
[KEY_SAVED]: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ab so vielen Tagen vor dem Ablauf warnt das CRM. */
|
||||||
|
export const WARN_DAYS = 30;
|
||||||
|
|
||||||
|
/** Kennzeichnet das Format - falls das Verfahren je wechselt. */
|
||||||
|
const PREFIX = 'v1:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluesselmaterial: bevorzugt CRM_ENCRYPTION_KEY, sonst JWT_SECRET. Der
|
||||||
|
* Rueckfall erspart eine zweite Pflichtvariable; wer JWT_SECRET wechselt, muss
|
||||||
|
* das Secret allerdings neu eingeben (die Oberflaeche sagt das dann auch).
|
||||||
|
*/
|
||||||
|
function cipherKey() {
|
||||||
|
const material = process.env.CRM_ENCRYPTION_KEY || process.env.JWT_SECRET || '';
|
||||||
|
if (material.length < 16) {
|
||||||
|
throw new Error('Kein Schlüssel zum Verschlüsseln: CRM_ENCRYPTION_KEY oder JWT_SECRET setzen.');
|
||||||
|
}
|
||||||
|
return crypto.scryptSync(material, 'perinco-crm-ms-secret', 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exportiert nur fuer den Test - im Betrieb geht beides ueber saveSecret(). */
|
||||||
|
export function encrypt(text) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', cipherKey(), iv);
|
||||||
|
const data = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
|
||||||
|
return PREFIX + Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entschluesselt; leerer String, wenn der Schluessel nicht mehr passt. */
|
||||||
|
export function decrypt(value) {
|
||||||
|
if (!value || !value.startsWith(PREFIX)) return '';
|
||||||
|
try {
|
||||||
|
const raw = Buffer.from(value.slice(PREFIX.length), 'base64');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', cipherKey(), raw.subarray(0, 12));
|
||||||
|
decipher.setAuthTag(raw.subarray(12, 28));
|
||||||
|
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envSecret = () => (process.env.MS_CLIENT_SECRET || '').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rohwerte aus der Datenbank.
|
||||||
|
*
|
||||||
|
* Ist die Datenbank nicht erreichbar, gilt das als "nichts hinterlegt" - dann
|
||||||
|
* greift der Wert aus der .env. Eine nicht erreichbare Datenbank ist ohnehin
|
||||||
|
* ein groesseres Problem als ein fehlendes Secret, und der Postfachabgleich
|
||||||
|
* soll daran nicht mit einer irrefuehrenden Meldung scheitern.
|
||||||
|
*/
|
||||||
|
async function stored() {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = await getSettings(DEFAULTS);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Microsoft] Secret konnte nicht gelesen werden, nutze .env:', err.message);
|
||||||
|
raw = { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cipher: raw[KEY_CIPHER] || '',
|
||||||
|
expiresAt: raw[KEY_EXPIRES] || '',
|
||||||
|
savedAt: raw[KEY_SAVED] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das tatsaechlich benutzte Secret samt Herkunft. Absichtlich ohne
|
||||||
|
* Zwischenspeicher: der Aufruf passiert selten (Tokens werden zwischengespeichert),
|
||||||
|
* und eine Aenderung soll sofort greifen.
|
||||||
|
*/
|
||||||
|
export async function effectiveSecret() {
|
||||||
|
const { cipher } = await stored();
|
||||||
|
if (cipher) {
|
||||||
|
const value = decrypt(cipher);
|
||||||
|
if (value) return { value, source: 'db' };
|
||||||
|
// Schluessel passt nicht mehr - nicht stillschweigend auf die .env
|
||||||
|
// zurueckfallen, sonst sucht man den Fehler an der falschen Stelle.
|
||||||
|
return { value: '', source: 'db-unlesbar' };
|
||||||
|
}
|
||||||
|
const value = envSecret();
|
||||||
|
return value ? { value, source: 'env' } : { value: '', source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die Konfigurationspruefung: gibt es ueberhaupt ein brauchbares Secret? */
|
||||||
|
export async function hasSecret() {
|
||||||
|
const { value } = await effectiveSecret();
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wirft mit klarer Meldung, wenn keins da ist - Gegenstueck zum alten clientSecret(). */
|
||||||
|
export async function requireSecret() {
|
||||||
|
const { value, source } = await effectiveSecret();
|
||||||
|
if (value) return value;
|
||||||
|
if (source === 'db-unlesbar') {
|
||||||
|
throw new Error(
|
||||||
|
'Das hinterlegte Client-Secret lässt sich nicht entschlüsseln. Wurde JWT_SECRET oder ' +
|
||||||
|
'CRM_ENCRYPTION_KEY geändert? Dann das Secret in den Einstellungen neu eintragen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tage bis zum Ablauf, auf Kalendertage gerechnet. Null ohne Datum. */
|
||||||
|
export function daysUntil(date, today = new Date()) {
|
||||||
|
if (!date) return null;
|
||||||
|
const target = Date.parse(`${date}T00:00:00Z`);
|
||||||
|
if (!Number.isFinite(target)) return null;
|
||||||
|
const start = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
||||||
|
return Math.round((target - start) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ok`, `warnung`, `abgelaufen` oder `unbekannt` (kein Datum hinterlegt). */
|
||||||
|
export function expiryLevel(days) {
|
||||||
|
if (days === null) return 'unbekannt';
|
||||||
|
if (days < 0) return 'abgelaufen';
|
||||||
|
if (days <= WARN_DAYS) return 'warnung';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustand fuer die Oberflaeche - ohne das Secret selbst. */
|
||||||
|
export async function secretStatus() {
|
||||||
|
const { expiresAt, savedAt } = await stored();
|
||||||
|
const { value, source } = await effectiveSecret();
|
||||||
|
const days = daysUntil(expiresAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: Boolean(value),
|
||||||
|
/** 'db', 'env', 'db-unlesbar' oder null */
|
||||||
|
source,
|
||||||
|
expires_at: expiresAt || null,
|
||||||
|
saved_at: savedAt || null,
|
||||||
|
days_left: days,
|
||||||
|
level: expiryLevel(days),
|
||||||
|
warn_days: WARN_DAYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leeres Datum loescht den Eintrag; ungueltiges Datum wird abgewiesen. */
|
||||||
|
export function normalizeExpiry(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if (text === '') return { value: '' };
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||||||
|
return { error: 'Das Ablaufdatum muss im Format JJJJ-MM-TT angegeben werden.' };
|
||||||
|
}
|
||||||
|
const parsed = new Date(`${text}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) {
|
||||||
|
return { error: 'Dieses Ablaufdatum gibt es nicht.' };
|
||||||
|
}
|
||||||
|
return { value: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft ein Secret bei Microsoft, bevor es gespeichert wird: ein
|
||||||
|
* Client-Credentials-Token gegen denselben Tenant. Ein falsches Secret faellt so
|
||||||
|
* sofort auf, statt beim naechsten Postfachabgleich.
|
||||||
|
*/
|
||||||
|
export async function verifySecret(candidate) {
|
||||||
|
const tenant = (process.env.MS_TENANT_ID || '').trim();
|
||||||
|
const client = (process.env.MS_CLIENT_ID || '').trim();
|
||||||
|
if (!tenant || !client) {
|
||||||
|
return { ok: false, error: 'MS_TENANT_ID oder MS_CLIENT_ID fehlt in der .env.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: client,
|
||||||
|
client_secret: candidate,
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: `Microsoft ist nicht erreichbar: ${err.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !data?.access_token) {
|
||||||
|
const detail = data?.error_description || data?.error || `HTTP ${response.status}`;
|
||||||
|
return { ok: false, error: String(detail).split('\n')[0] };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert Secret und/oder Ablaufdatum. `secret` leer bedeutet: nur das Datum
|
||||||
|
* anfassen, das gespeicherte Secret bleibt.
|
||||||
|
*/
|
||||||
|
export async function saveSecret({ secret = '', expiresAt = null } = {}) {
|
||||||
|
if (secret) {
|
||||||
|
await setSetting(KEY_CIPHER, encrypt(secret));
|
||||||
|
await setSetting(KEY_SAVED, new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (expiresAt !== null) {
|
||||||
|
await setSetting(KEY_EXPIRES, expiresAt);
|
||||||
|
}
|
||||||
|
return secretStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt das hinterlegte Secret zurueck - ab dann gilt wieder die .env. */
|
||||||
|
export async function clearStoredSecret() {
|
||||||
|
await setSetting(KEY_CIPHER, '');
|
||||||
|
await setSetting(KEY_SAVED, '');
|
||||||
|
return secretStatus();
|
||||||
|
}
|
||||||
@@ -11,10 +11,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
ALLOWED_DOMAINS,
|
ALLOWED_DOMAINS,
|
||||||
REDIRECT_URI,
|
REDIRECT_URI,
|
||||||
SSO_CONFIGURED,
|
|
||||||
domainAllowed,
|
domainAllowed,
|
||||||
missingSsoConfig,
|
missingSsoConfig,
|
||||||
|
ssoConfigured,
|
||||||
} from '../sso/config.js';
|
} from '../sso/config.js';
|
||||||
|
import {
|
||||||
|
clearStoredSecret,
|
||||||
|
normalizeExpiry,
|
||||||
|
saveSecret,
|
||||||
|
secretStatus,
|
||||||
|
verifySecret,
|
||||||
|
} from '../msSecret.js';
|
||||||
|
import { resetTokenCache } from '../mail/graph.js';
|
||||||
import {
|
import {
|
||||||
SsoError,
|
SsoError,
|
||||||
beginLogin,
|
beginLogin,
|
||||||
@@ -183,31 +191,99 @@ function redirectWithError(res, message) {
|
|||||||
|
|
||||||
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
|
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
|
||||||
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
|
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
|
||||||
router.get('/sso/status', (req, res) => {
|
router.get('/sso/status', async (req, res, next) => {
|
||||||
res.json({ enabled: SSO_CONFIGURED });
|
try {
|
||||||
|
res.json({ enabled: await ssoConfigured() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
|
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
|
||||||
router.get('/sso/config', requireAuth, requireAdmin, (req, res) => {
|
router.get('/sso/config', requireAuth, requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
res.json({
|
res.json({
|
||||||
enabled: SSO_CONFIGURED,
|
enabled: await ssoConfigured(),
|
||||||
missing: missingSsoConfig(),
|
missing: await missingSsoConfig(),
|
||||||
redirect_uri: REDIRECT_URI || null,
|
redirect_uri: REDIRECT_URI || null,
|
||||||
allowed_domains: ALLOWED_DOMAINS,
|
allowed_domains: ALLOWED_DOMAINS,
|
||||||
|
secret: await secretStatus(),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Client-Secret der App-Registrierung
|
||||||
|
*
|
||||||
|
* Dasselbe Secret bedient Anmeldung und Postfachanbindung. Es liegt
|
||||||
|
* verschluesselt in crm_settings (siehe msSecret.js); der Wert aus der .env
|
||||||
|
* bleibt Rueckfallebene. Ausgeliefert wird das Secret nie - weder ganz noch
|
||||||
|
* teilweise, auch nicht an Administratoren.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// PUT /api/auth/microsoft-secret { secret?, expires_at? }
|
||||||
|
router.put('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
|
||||||
|
const secret = String(req.body?.secret ?? '').trim();
|
||||||
|
const hasExpiry = req.body?.expires_at !== undefined;
|
||||||
|
|
||||||
|
const expiry = hasExpiry ? normalizeExpiry(req.body.expires_at) : { value: null };
|
||||||
|
if (expiry.error) return res.status(400).json({ error: expiry.error });
|
||||||
|
|
||||||
|
if (!secret && !hasExpiry) {
|
||||||
|
return res.status(400).json({ error: 'Kein Secret und kein Ablaufdatum übergeben.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Erst bei Microsoft ausprobieren, dann speichern: ein vertipptes Secret
|
||||||
|
// legt sonst still Anmeldung und Postfachabgleich lahm.
|
||||||
|
if (secret) {
|
||||||
|
const check = await verifySecret(secret);
|
||||||
|
if (!check.ok) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Microsoft hat dieses Secret abgelehnt: ${check.error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await saveSecret({ secret, expiresAt: expiry.value });
|
||||||
|
|
||||||
|
// Das zwischengespeicherte Graph-Token stammt noch vom alten Secret.
|
||||||
|
if (secret) resetTokenCache();
|
||||||
|
|
||||||
|
res.json({ ok: true, verified: Boolean(secret), secret: status });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/microsoft-secret - hinterlegtes Secret verwerfen, .env gilt wieder
|
||||||
|
router.delete('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = await clearStoredSecret();
|
||||||
|
resetTokenCache();
|
||||||
|
res.json({ ok: true, secret: status });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
|
// GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
|
||||||
router.get('/sso/start', (req, res) => {
|
router.get('/sso/start', async (req, res, next) => {
|
||||||
if (!SSO_CONFIGURED) {
|
try {
|
||||||
|
if (!(await ssoConfigured())) {
|
||||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||||
}
|
}
|
||||||
res.redirect(302, beginLogin());
|
res.redirect(302, beginLogin());
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
|
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
|
||||||
router.get('/sso/callback', async (req, res, next) => {
|
router.get('/sso/callback', async (req, res, next) => {
|
||||||
if (!SSO_CONFIGURED) {
|
if (!(await ssoConfigured())) {
|
||||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { Match } from '../scanner/models/index.js';
|
import { Match } from '../scanner/models/index.js';
|
||||||
|
import { secretStatus } from '../msSecret.js';
|
||||||
import {
|
import {
|
||||||
ARCHIVE_AFTER_DAYS,
|
ARCHIVE_AFTER_DAYS,
|
||||||
DELETE_AFTER_DAYS,
|
DELETE_AFTER_DAYS,
|
||||||
@@ -54,12 +55,35 @@ router.get('/', async (req, res, next) => {
|
|||||||
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl.
|
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hinweis auf das bald ablaufende Client-Secret. Er reist mit den Zaehlern
|
||||||
|
* mit, weil die ohnehin bei jedem Laden der App geholt werden - ein eigener
|
||||||
|
* Endpunkt waere eine zweite Anfrage fuer eine Zahl. Nur Administratoren
|
||||||
|
* bekommen ihn: nur sie koennen das Secret erneuern.
|
||||||
|
*/
|
||||||
|
let msSecret = null;
|
||||||
|
if (req.user.role === 'admin') {
|
||||||
|
try {
|
||||||
|
const status = await secretStatus();
|
||||||
|
if (status.level === 'warnung' || status.level === 'abgelaufen') {
|
||||||
|
msSecret = {
|
||||||
|
level: status.level,
|
||||||
|
days_left: status.days_left,
|
||||||
|
expires_at: status.expires_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein Grund, die Zaehler scheitern zu lassen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
acquisition_archive: Number(counts.acquisition_archive),
|
acquisition_archive: Number(counts.acquisition_archive),
|
||||||
acquisition_trash: Number(counts.acquisition_trash),
|
acquisition_trash: Number(counts.acquisition_trash),
|
||||||
opportunity_trash: Number(counts.opportunity_trash),
|
opportunity_trash: Number(counts.opportunity_trash),
|
||||||
mail_trash: Number(counts.mail_trash),
|
mail_trash: Number(counts.mail_trash),
|
||||||
scanner_rejected: scannerRejected,
|
scanner_rejected: scannerRejected,
|
||||||
|
ms_secret: msSecret,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { requireAdmin } from '../middleware/auth.js';
|
|||||||
import { setSetting } from '../settings.js';
|
import { setSetting } from '../settings.js';
|
||||||
import {
|
import {
|
||||||
MAILBOX,
|
MAILBOX,
|
||||||
MAIL_CONFIGURED,
|
mailConfigured,
|
||||||
MAIL_FOLDER,
|
MAIL_FOLDER,
|
||||||
MAIL_SETTING_DEFAULTS,
|
MAIL_SETTING_DEFAULTS,
|
||||||
SCHEDULER_ENABLED,
|
SCHEDULER_ENABLED,
|
||||||
@@ -70,6 +70,7 @@ router.get('/status', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const settings = await mailSettings();
|
const settings = await mailSettings();
|
||||||
const state = await getSyncState();
|
const state = await getSyncState();
|
||||||
|
const configured = await mailConfigured();
|
||||||
|
|
||||||
const [[counts]] = await pool.query(
|
const [[counts]] = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -86,8 +87,8 @@ router.get('/status', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
configured: MAIL_CONFIGURED,
|
configured,
|
||||||
missing: MAIL_CONFIGURED ? [] : missingCredentials(),
|
missing: configured ? [] : await missingCredentials(),
|
||||||
mailbox: MAILBOX,
|
mailbox: MAILBOX,
|
||||||
folder: MAIL_FOLDER,
|
folder: MAIL_FOLDER,
|
||||||
scheduler_enabled: SCHEDULER_ENABLED,
|
scheduler_enabled: SCHEDULER_ENABLED,
|
||||||
@@ -167,11 +168,11 @@ router.post('/settings', requireAdmin, async (req, res, next) => {
|
|||||||
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
|
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
|
||||||
router.post('/test', requireAdmin, async (req, res, next) => {
|
router.post('/test', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!MAIL_CONFIGURED) {
|
if (!(await mailConfigured())) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
stage: 'konfiguration',
|
stage: 'konfiguration',
|
||||||
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`,
|
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
|
||||||
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
|
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,8 +185,10 @@ router.post('/test', requireAdmin, async (req, res, next) => {
|
|||||||
// POST /api/mail/sync - Abgleich sofort anstossen
|
// POST /api/mail/sync - Abgleich sofort anstossen
|
||||||
router.post('/sync', requireAdmin, async (req, res, next) => {
|
router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!MAIL_CONFIGURED) {
|
if (!(await mailConfigured())) {
|
||||||
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
|
return res.status(409).json({
|
||||||
|
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (isMailSyncRunning()) {
|
if (isMailSyncRunning()) {
|
||||||
return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
|
return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
|
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { hasSecret, requireSecret } from '../msSecret.js';
|
||||||
|
|
||||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
|
||||||
|
|
||||||
/** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
|
/** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
|
||||||
const ENABLED = process.env.MS_SSO_ENABLED === 'true';
|
const ENABLED = process.env.MS_SSO_ENABLED === 'true';
|
||||||
@@ -40,24 +41,27 @@ export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
|
|||||||
/** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
|
/** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
|
||||||
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read'];
|
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read'];
|
||||||
|
|
||||||
/** Ohne diese Werte ist die Anmeldung nicht betriebsbereit. */
|
/*
|
||||||
export const SSO_CONFIGURED = Boolean(ENABLED && TENANT_ID && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
|
* Ohne diese Werte ist die Anmeldung nicht betriebsbereit. Asynchron, weil das
|
||||||
|
* Secret auch aus der Datenbank kommen darf (msSecret.js).
|
||||||
export function clientSecret() {
|
*/
|
||||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
export async function ssoConfigured() {
|
||||||
return CLIENT_SECRET;
|
return Boolean(ENABLED && TENANT_ID && CLIENT_ID && REDIRECT_URI && (await hasSecret()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das gueltige Client-Secret: Datenbank vor .env. */
|
||||||
|
export const clientSecret = requireSecret;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
|
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
|
||||||
* ohne Werte zu verraten.
|
* ohne Werte zu verraten.
|
||||||
*/
|
*/
|
||||||
export function missingSsoConfig() {
|
export async function missingSsoConfig() {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!ENABLED) missing.push('MS_SSO_ENABLED');
|
if (!ENABLED) missing.push('MS_SSO_ENABLED');
|
||||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
if (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
|
||||||
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
|
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ async function postToken(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
|
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
|
||||||
export function exchangeCode(code, verifier) {
|
export async function exchangeCode(code, verifier) {
|
||||||
return postToken({
|
return postToken({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
client_secret: clientSecret(),
|
client_secret: await clientSecret(),
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Prueft das hinterlegte Client-Secret, soweit es ohne Datenbank geht:
|
||||||
|
* Verschluesselung, Ablaufrechnung und die Pruefung des Datums.
|
||||||
|
*
|
||||||
|
* Der Schluessel muss vor dem Import stehen - msSecret.js liest ihn beim
|
||||||
|
* Verschluesseln aus der Umgebung, genau wie im Betrieb.
|
||||||
|
*/
|
||||||
|
process.env.JWT_SECRET = 'test-schluessel-lang-genug-fuer-die-pruefung';
|
||||||
|
delete process.env.CRM_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
const { WARN_DAYS, daysUntil, decrypt, encrypt, expiryLevel, normalizeExpiry } = await import(
|
||||||
|
'../src/msSecret.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
function check(name, condition, detail = '') {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nClient-Secret');
|
||||||
|
|
||||||
|
console.log('\n Verschluesselung');
|
||||||
|
const klartext = 'Abc8Q~geheimes-client-secret-aus-entra';
|
||||||
|
const cipher = encrypt(klartext);
|
||||||
|
check('Ergebnis ist nicht der Klartext', !cipher.includes('geheimes-client-secret'));
|
||||||
|
check('Format ist gekennzeichnet', cipher.startsWith('v1:'));
|
||||||
|
check('Entschluesseln ergibt das Original', decrypt(cipher) === klartext);
|
||||||
|
check(
|
||||||
|
'zweimal verschluesselt ergibt zwei verschiedene Werte',
|
||||||
|
encrypt(klartext) !== encrypt(klartext),
|
||||||
|
'eigener Zufallsvektor je Aufruf'
|
||||||
|
);
|
||||||
|
check('veraenderter Text wird nicht entschluesselt', decrypt(`${cipher}xx`) === '', 'GCM erkennt es');
|
||||||
|
check('fremdes Format wird abgelehnt', decrypt('irgendwas') === '');
|
||||||
|
check('leerer Wert bleibt leer', decrypt('') === '');
|
||||||
|
|
||||||
|
// Ein anderer Schluessel darf nicht passen - genau das passiert, wenn jemand
|
||||||
|
// JWT_SECRET wechselt. Die Oberflaeche zeigt dann "nicht entschluesselbar".
|
||||||
|
process.env.CRM_ENCRYPTION_KEY = 'ein-ganz-anderer-schluessel-fuer-den-test';
|
||||||
|
check('anderer Schluessel entschluesselt nicht', decrypt(cipher) === '');
|
||||||
|
delete process.env.CRM_ENCRYPTION_KEY;
|
||||||
|
check('mit dem alten Schluessel geht es wieder', decrypt(cipher) === klartext);
|
||||||
|
|
||||||
|
console.log('\n Restlaufzeit');
|
||||||
|
const heute = new Date('2026-09-21T08:00:00Z');
|
||||||
|
check('heute sind es 0 Tage', daysUntil('2026-09-21', heute) === 0);
|
||||||
|
check('morgen ist 1 Tag', daysUntil('2026-09-22', heute) === 1);
|
||||||
|
check('gestern sind -1 Tage', daysUntil('2026-09-20', heute) === -1);
|
||||||
|
check('ohne Datum null', daysUntil('', heute) === null);
|
||||||
|
check('unlesbares Datum ergibt null', daysUntil('irgendwann', heute) === null);
|
||||||
|
check('Jahreswechsel zaehlt richtig', daysUntil('2027-09-21', heute) === 365);
|
||||||
|
|
||||||
|
console.log('\n Warnstufe');
|
||||||
|
check('kein Datum: unbekannt', expiryLevel(null) === 'unbekannt');
|
||||||
|
check('abgelaufen bei -1', expiryLevel(-1) === 'abgelaufen');
|
||||||
|
check('Warnung am Tag des Ablaufs', expiryLevel(0) === 'warnung');
|
||||||
|
check(`Warnung genau ${WARN_DAYS} Tage vorher`, expiryLevel(WARN_DAYS) === 'warnung');
|
||||||
|
check('einen Tag frueher noch ok', expiryLevel(WARN_DAYS + 1) === 'ok');
|
||||||
|
|
||||||
|
console.log('\n Ablaufdatum pruefen');
|
||||||
|
check('gueltiges Datum wird uebernommen', normalizeExpiry('2027-03-01').value === '2027-03-01');
|
||||||
|
check('leer loescht den Eintrag', normalizeExpiry('').value === '');
|
||||||
|
check('deutsches Format wird abgelehnt', Boolean(normalizeExpiry('01.03.2027').error));
|
||||||
|
check('31. Februar wird abgelehnt', Boolean(normalizeExpiry('2027-02-31').error));
|
||||||
|
check('Monat 13 wird abgelehnt', Boolean(normalizeExpiry('2027-13-01').error));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`
|
||||||
|
);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
@@ -15,7 +15,7 @@ process.env.MS_CLIENT_SECRET = 'geheim-fuer-den-test';
|
|||||||
process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
|
process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
|
||||||
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de';
|
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de';
|
||||||
|
|
||||||
const { SSO_CONFIGURED, domainAllowed, missingSsoConfig } = await import('../src/sso/config.js');
|
const { domainAllowed, missingSsoConfig, ssoConfigured } = await import('../src/sso/config.js');
|
||||||
const {
|
const {
|
||||||
SsoError,
|
SsoError,
|
||||||
beginLogin,
|
beginLogin,
|
||||||
@@ -62,8 +62,11 @@ const validClaims = {
|
|||||||
console.log('\nAnmeldung ueber Microsoft (SSO)');
|
console.log('\nAnmeldung ueber Microsoft (SSO)');
|
||||||
|
|
||||||
console.log('\n Konfiguration');
|
console.log('\n Konfiguration');
|
||||||
check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true);
|
// Ohne erreichbare Datenbank faellt das Secret auf die .env zurueck - genau
|
||||||
check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', '));
|
// dieser Weg wird hier mitgeprueft.
|
||||||
|
const missing = await missingSsoConfig();
|
||||||
|
check('vollstaendige Konfiguration gilt als betriebsbereit', (await ssoConfigured()) === true);
|
||||||
|
check('nichts fehlt', missing.length === 0, missing.join(', '));
|
||||||
check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
|
check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
|
||||||
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
|
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
|
||||||
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
|
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
|
||||||
|
|||||||
+36
@@ -228,6 +228,42 @@ const App: React.FC = () => {
|
|||||||
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
|
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">
|
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
|
||||||
{currentView === 'Dashboard' && (
|
{currentView === 'Dashboard' && (
|
||||||
<Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} />
|
<Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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';
|
import { useToast } from '../Toast';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Anmeldung über Microsoft Entra - Adminsicht.
|
* Anmeldung über Microsoft Entra - Adminsicht.
|
||||||
*
|
*
|
||||||
* Hier lässt sich nichts umstellen, und das ist Absicht: Tenant, Client-ID,
|
* Tenant-ID, Client-ID und Umleitungsadresse stehen in server/.env und sind
|
||||||
* Secret und Umleitungsadresse stehen in server/.env und tauchen damit weder in
|
* hier nur ablesbar. Das Client-Secret ist die Ausnahme: es läuft in Entra ab
|
||||||
* der Oberfläche noch in einem Datenbank-Dump auf. Diese Seite zeigt nur, ob
|
* und muss dann erneuert werden, ohne dass jemand auf den Server muss. Es geht
|
||||||
* die Anbindung steht, und nennt die Werte, die in Entra eingetragen sein
|
* deshalb hier hinein - verschlüsselt in die Datenbank - kommt aber nie wieder
|
||||||
* müssen.
|
* heraus. Angezeigt wird nur, woher es stammt und wie lange es noch gilt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
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>
|
</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 = () => {
|
export const SsoSettings: React.FC = () => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const [config, setConfig] = useState<SsoConfig | null>(null);
|
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">
|
<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(', ')}
|
Fehlt in <code>server/.env</code>: {config.missing.join(', ')}
|
||||||
<span className="mt-1 block text-xs font-normal">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||||
<h3 className="text-base text-ink-900">Wie es funktioniert</h3>
|
<h3 className="text-base text-ink-900">Wie es funktioniert</h3>
|
||||||
<ul className="mt-4 space-y-3 text-sm text-ink-600">
|
<ul className="mt-4 space-y-3 text-sm text-ink-600">
|
||||||
@@ -105,6 +326,28 @@ export const SsoSettings: React.FC = () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,11 +119,24 @@ export const authApi = {
|
|||||||
*/
|
*/
|
||||||
export const SSO_START_URL = '/api/auth/sso/start';
|
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 {
|
export interface SsoConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
missing: string[];
|
missing: string[];
|
||||||
redirect_uri: string | null;
|
redirect_uri: string | null;
|
||||||
allowed_domains: string[];
|
allowed_domains: string[];
|
||||||
|
secret: MsSecretStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ssoApi = {
|
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) ----------
|
// ---------- Benutzerverwaltung (nur admin) ----------
|
||||||
|
|
||||||
export interface UserInput {
|
export interface UserInput {
|
||||||
@@ -312,6 +340,11 @@ export interface SidebarBadges {
|
|||||||
opportunity_trash: number;
|
opportunity_trash: number;
|
||||||
mail_trash: number;
|
mail_trash: number;
|
||||||
scanner_rejected: number | null;
|
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 = {
|
export const badgesApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user