OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
08 Anleitung für Entwickler
Für jemanden, der das Projekt zum ersten Mal öffnet. Was man braucht, wie man es startet, wo man was ändert - und welche Fallen es gibt.
In zwei Minuten zum Bild
- Ein Vite-Frontend (React 19, TypeScript, Tailwind 4), ein Express-Backend, zwei MariaDB-Datenbanken.
- Kein Router im Frontend:
App.tsxhält die Ansicht in einemuseState. Die App liegt hinter einer Anmeldung, tiefe Links braucht sie nicht. - Kein ORM im CRM-Teil - dort schreibt das Backend SQL mit
mysql2. Der Scanner-Teil benutzt Sequelize, weil er 1:1 aus einer anderen Anwendung übernommen wurde. - Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklären das Warum, nicht das Was.
Voraussetzungen
| Version | |
|---|---|
| Node | 20 oder neuer (Server läuft 20.20) |
| MariaDB | 10.11 oder neuer, erreichbar mit Benutzer und Passwort |
| Browser | Chrome/Edge für WebGPU beim Diktat (sonst läuft es auf der CPU) |
Lokal starten
git clone https://git.perinco.de/Sascha/perinco-CRM.git
cd perinco-CRM
npm install # Frontend
cd server && npm install # Backend
cp .env.example .env # Werte eintragen, mindestens DB_* und JWT_SECRET
npm start # Backend auf :5006
cd .. && npm run dev # Frontend auf :3100, /api wird geproxied
Was dabei von selbst passiert:
perinco_CRMmuss existieren; die Tabellen legtserver/src/schema.sqlbeim Start an (idempotent).perinco_scannerlegt das Backend komplett selbst an - Datenbank, Tabellen, Portale, Standardeinstellungen.- Fehlt
ADMIN_PASSWORD, wird beim ersten Start ein Zufallspasswort erzeugt und einmalig ins Log geschrieben. Dort nachsehen, sonst kommt man nicht rein.
Ohne MariaDB startet das Backend nicht. Das Frontend lässt sich trotzdem ansehen - siehe Oberfläche prüfen ohne Datenbank weiter unten.
Prüfen
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Postfachanbindung, Berater-Liste, Benutzerfelder, LinkedIn-Foto, Sitzungsbenutzer
Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines Node und ohne Datenbank:
server/test/mail.test.mjsprüft die Kontaktextraktion aus Mails und den Graph-Client. Beides läuft unbeaufsichtigt im Hintergrund und lässt sich von Hand kaum nachstellen - ein falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten Dienst kommt genau dann, wenn niemand hinsieht.server/test/acquisition.test.mjsprüft das Zerlegen des Feldes Berater, das mehrere Namen kommagetrennt hält. Es steckt im Speichern, im Verlauf, in der Auswertung und in der Übernahme; ein Fehler darin fällt nicht auf, weil die Liste weiter Namen zeigt und nur die Zahlen falsch wären.server/test/sessionUser.test.mjsvergleicht die Spaltenliste inrequireAuthmit den Feldern, diepublicUser()ausliefert. Fehlt dort eine Spalte, antwortet die API still mitnull- so ist die gespeicherte Designwahl beim Neuladen verloren gegangen.
Für alles andere gilt: tsc, ein Build und ein Blick in die Oberfläche - notfalls
mit dem Stub aus Oberfläche prüfen ohne Datenbank.
Aufbau
src/ Frontend
App.tsx Ansicht, zentrale Daten, Layout
types.ts Typen, Konstanten, Rechenhilfen (Volumen, Farben)
lib/ API-Zugriff und Kontexte
components/ je Bereich ein Ordner
server/src/ Backend
server.js Express-App, Reihenfolge der Routen
db.js Verbindungspool, Schema-Init, Admin-Seed
settings.js crm_settings lesen und schreiben (ohne Geheimnisse)
routes/ ein Router je Ressource
middleware/auth.js JWT prüfen, Adminrechte
mail/ Postfach crm@perinco.de über Microsoft Graph
scanner/ portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
Wie eine Anfrage läuft
sequenceDiagram
participant K as Komponente
participant A as lib/api.ts
participant E as Express
participant M as middleware/auth
participant R as routes/*.js
participant D as MariaDB
K->>A: leadsApi.update(7, { status: 'Gewonnen' })
A->>A: Token anhängen, Content-Type setzen
A->>E: PATCH /api/leads/7
E->>M: requireAuth
M->>D: Benutzer laden, aktiv?
M->>R: req.user gesetzt
R->>R: Eingaben prüfen und normalisieren
R->>D: UPDATE ... ; SELECT (angereichert)
R-->>A: 200 JSON
A-->>K: typisiertes Objekt
Jede Route liefert den vollständigen, angereicherten Datensatz zurück (mit
created_by_name, owner_name, Ansprechpartnern, Teilnehmern). Das Frontend muss
danach nichts nachladen.
Etwas hinzufügen
Neues Feld an einem Kontakt
server/src/schema.sql: Spalte mitALTER TABLE ... ADD COLUMN IF NOT EXISTSergänzen - das Statement muss beim nächsten Start wiederholbar sein.server/src/routes/leads.js: Feld in die ListenTEXT_FIELDSbzw. inINSERT/UPDATEund in denSELECTaufnehmen.src/types.ts: Feld insLead-Interface.src/components/leads/LeadModal.tsx: Eingabefeld,inputClassbenutzen.- Wenn es in Kachel oder Liste sichtbar sein soll:
LeadGrid.tsx,LeadList.tsx.
Neuer Menüpunkt
src/types.ts: Wert inViewStateergänzen.src/components/Sidebar.tsx: Eintrag inCATEGORY_ENTRIES(Name + SVG-Pfad).src/App.tsx: Komponente rendern und einen Untertitel setzen.
Der Name in ViewState ist gleichzeitig die Beschriftung und der Titel in der
Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt label am MenuItem
(so macht es der Unterpunkt Postfach, der auf die Ansicht Mails zeigt).
Soll der Punkt aufklappen, kommt statt eines MenuItem eine MenuGroup in
CATEGORY_ENTRIES - mit einer Liste ihrer Ansichten in types.ts
(CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS), damit die Gruppe offen bleibt,
solange eine ihrer Ansichten aktiv ist.
Neuer Reiter in den Einstellungen
src/types.ts: Wert inSettingsTab; nur für Admins zusätzlich inADMIN_SETTINGS_TABS.src/components/settings/Settings.tsx: Eintrag inTABSund Rendering.
Ein Sprung dorthin aus einer beliebigen Tiefe:
goTo('Einstellungen', { tab: 'whisper' }).
Neuer Endpunkt
- Router in
server/src/routes/anlegen,router.use(requireAuth)nicht vergessen,requireAdminwo nötig. - In
server/src/server.jseinhängen - vor dem/api-404-Fallback. - In
src/lib/api.tseine typisierte Funktion ergänzen.
Neue Einstellung im CRM
Nur für Werte, die im Betrieb verstellbar sein sollen. Zugangsdaten gehören
nicht dazu - die stehen in server/.env und werden beim Start gelesen, damit ein
Datenbank-Dump keine Geheimnisse enthält.
- Schlüssel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
MAIL_SETTING_DEFAULTSinserver/src/mail/config.js). Es ist gleichzeitig die Positivliste des Speicher-Endpunkts. - Lesen mit
getSettings(defaults)ausserver/src/settings.js, schreiben mitsetSetting(). Zahlen und Schalter mitasInt/asBoolwandeln. - Feld im passenden Einstellungsreiter ergänzen.
Die Tabelle crm_settings legt schema.sql an; ein neuer Schlüssel braucht keine
Migration.
Fallen
Diese Punkte haben real Zeit gekostet:
| Falle | Was passiert |
|---|---|
Reihenfolge in server.js |
Der 404-Fallback app.use('/api', ...) fängt alles, was danach registriert wird. Neue Router davor einhängen. |
Content-Type bei FormData |
apiRequest setzt JSON nur, wenn der Body keine FormData ist - sonst fehlt die multipart-Grenze und der Upload scheitert. |
| Dateien in neuen Tabs | Ein <a href> schickt kein Bearer-Token. Deshalb holt openAgentCv() das PDF per fetch und öffnet einen Blob. |
.env ist gleichzeitig EnvironmentFile |
Also kein export, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. |
| Zeitstempel können nur vorziehen | Ein „vorgealterter" lastRunTime verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist. |
| Sperrfristen bei jedem Start neu | Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt last_scan_at in der Datenbank. |
sequelize.sync({ alter: true }) |
Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur sync() benutzen. |
| Kaskaden beim Löschen | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| Doppelte Scanner-Läufe | Scheduler und Jetzt scannen teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| Anmeldetoken in der Adresszeile | Die Rückleitung von Microsoft ist ein GET. Stünde das CRM-Token darin, läge es in Browserverlauf und Apache-Log. Deshalb kommt ein Einmal-Ticket zurück, das der Browser per POST tauscht - siehe 13 Anmeldung über Microsoft. |
allow-same-origin bei srcDoc |
Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann localStorage lesen - also das Anmeldetoken. Fremdes HTML gehört in sandbox="" ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe. |
/me unter App-only |
Es gibt keinen Benutzer im Token, also auch kein /me - jeder Graph-Aufruf muss /users/{postfach}/… lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde. |
| UTC neben Wandzeit | crm_mail_messages.received_at steht in UTC (so liefert Graph es), crm_events.starts_at in lokaler Wandzeit. Beim Lesen im Frontend parseMailDate() benutzen, sonst ist die Anzeige um Stunden verschoben. |
| Aufzählungen in der Datenbank sind deutsch | crm_mail_links.origin (manuell/automatisch), Vorschlagsstatus (offen, uebernommen, verworfen, dublette). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
Oberfläche prüfen ohne Datenbank
Für Bildschirmfotos und Klickwege genügt ein Stub: ein kleiner node:http-Server,
der dist/ ausliefert und auf /api/... erfundene Daten zurückgibt. Token vorab
setzen, dann ist die Anmeldung übersprungen:
await page.goto(BASE);
await page.evaluate(() => localStorage.setItem('perinco_crm_token', 'stub-token'));
await page.goto(BASE);
Playwright startet dabei am besten das installierte Chrome
(chromium.launch({ channel: 'chrome' })) - dann braucht es keinen
Browser-Download.
Konventionen
- Deutsch in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
englisch (
Match,Opportunity,Lead). - Kommentare erklären das Warum. Was der Code tut, steht im Code.
- Kein
dark:und keine Theme-Klasse im Markup. Nur die Skalenink-*,brand-*,canvas,surfacebenutzen, den Rest machtindex.css. Das ist nicht Kosmetik: daran hängen vier Farbwelten in je zwei Modi, und eine feste Farbe im Markup bricht sieben davon. Die Rollen der Skalen und die Regeln für ein neues Theme stehen in 05 Frontend - wichtigste davon: aufbrand-500/brand-600steht weisse Schrift. - Gemeinsame Klassen statt kopierter Utility-Ketten:
inputClass,labelClass,labelTextClass,labelRowClassauscomponents/leads/leadUi.tsx, dazucardClass,primaryButtonClassauscomponents/scanner/scannerUi.tsx. - Meldungen als Toast, Rückfragen über
ConfirmDialog- niealert()oderconfirm(). Rot (tone="danger", Standard) nur, wenn etwas verloren geht; sonsttone="neutral", damit die Farbe ihre Bedeutung behält. - Rechte immer doppelt: Oberfläche blendet aus, Backend antwortet mit 403.
Deployment
Siehe 06 Betrieb. Kurzform: lokal bauen, deploy/deploy.ps1
lädt dist/ und server/src hoch und startet den Dienst neu. Auf dem Server
wird nicht gebaut - dort sind RAM und Swap knapp.