Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82c2460835 | ||
|
|
dddfb02d1d | ||
|
|
b8e4de97c7 | ||
|
|
21325c4037 | ||
|
|
3af8a2f2ad |
No files matched your search
@@ -0,0 +1,240 @@
|
||||
# Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem ⇄ Projekt-Admin – Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Mitarbeiter melden sich in Ticketsystem und Projekt-Admin nur mit Passwort + E-Mail-Code an (Appwrite-MFA), und ein Seitenleisten-Eintrag wechselt ohne zweiten Login ins Projekt-Admin, ein Kopfzeilen-Button zurück.
|
||||
|
||||
**Architecture:** Appwrite 1.8.1 erzwingt den zweiten Faktor (`user.mfa` + `emailVerification`). Das Ticketsystem (React, SDK 13) ruft die MFA-Endpunkte über `client.call` auf; das Kundenportal (Express) führt beim Mitarbeiter-Login die Challenge serverseitig mit der Appwrite-Sitzung aus der Login-Antwort durch. Der Wechsel nutzt die vorhandenen Einmal-Tokens aus `staffPreviewTokens.js` mit neuem `purpose: 'switch'`.
|
||||
|
||||
**Tech Stack:** Kundenportal: Node/Express, `node --test`, Appwrite-Nachbau `test/support/appwriteMock.js`. Ticketsystem: React 18 + Vite, Appwrite-Web-SDK 13.0.2, neu `node --test` für reine Module; UI-Prüfung per esbuild-Harness + Playwright im Scratchpad (Rezept: Memory „woms-ticketsystem“, `MemoryMax` beachten).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md` (dieser Worktree)
|
||||
|
||||
**Repos / Worktrees:**
|
||||
- Ticketsystem: `/home/webklar/apps/ticket.webklar.com/dev-2fa`, Branch `feature/mitarbeiter-2fa` (von `origin/test` 3b6a821). `node_modules` ist getrackt → nur `git add <dateien>`, nie `-A`. **Nie pushen** (Webhook deployt jeden Branch).
|
||||
- Kundenportal: `/home/webklar/apps/kundenbereich.webklar.com/dev-2fa`, Branch `feature/mitarbeiter-2fa` von `origin/main` (d5c70a5), anlegen in Task 1 (`git -C build worktree add -b feature/mitarbeiter-2fa ../dev-2fa origin/main`, `node_modules` als Symlink auf `../build/node_modules`). Nicht pushen.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Appwrite-Endpunkte (1.8.1): `POST /v1/account/mfa/challenges {factor:'email'}` → `{ $id, expire }`; `PUT /v1/account/mfa/challenges {challengeId, otp}`; Admin: `PATCH /v1/users/:id/mfa {mfa}`, `PATCH /v1/users/:id/verification {emailVerification}`.
|
||||
- Fehlertypen: `user_more_factors_required` (401, halbe Sitzung), `user_invalid_token` (401, Code falsch/abgelaufen), `user_email_not_verified`, `general_rate_limit_exceeded` / Status 429.
|
||||
- Code gilt 1 Stunde (Appwrite). Offene Portal-Code-Anmeldung: 10 Minuten, max. 5 Fehlversuche.
|
||||
- Einmal-Link: 60 s, einmalig, `purpose: 'switch'`; Preview-Tokens ohne `purpose` gelten als `'preview'`.
|
||||
- MFA-Aufrufe senden `X-Appwrite-Locale: de`.
|
||||
- Texte (wörtlich, in beiden Systemen gleich):
|
||||
- Hinweis: `Wir haben dir einen Code an {maskierteEmail} geschickt.`
|
||||
- Feld-Label: `Code aus der E-Mail`; Buttons: `Anmelden`, `Code erneut senden`, `Abbrechen`, (halbe Sitzung beim Laden) `Code senden`
|
||||
- `user_invalid_token` → `Code falsch oder abgelaufen.`
|
||||
- 429 → `Zu viele Versuche. Bitte warte einige Minuten.`
|
||||
- `user_email_not_verified` → `2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.`
|
||||
- Portal nach 5 Fehlversuchen / abgelaufen → `Bitte melde dich erneut an.`
|
||||
- E-Mail-Maskierung: erstes Zeichen des lokalen Teils + `…@` + Domain (`kenso@webklar.com` → `k…@webklar.com`).
|
||||
- Seitenleiste: Label `Projekt-Admin`, Icon `IconExternalLink`; Kopfzeile Portal: `← Ticketsystem` → `https://ticket.webklar.com/tickets`.
|
||||
- Kunden-Login und Mitarbeiter mit `mfa = false` verhalten sich exakt wie heute.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Portal-Umgehung:** Mitarbeiter mit `mfa = true` darf nach `POST /api/auth/login` **kein** `webklar_portal_session`- und kein Preview-Cookie bekommen – Test in Task 3.
|
||||
2. **Reload mit halber Sitzung im Ticketsystem:** darf keine Mail automatisch auslösen und nicht in einer Login-Schleife enden – Test in Task 6 (UI-Harness).
|
||||
3. **Token-Verwechslung:** Ein Switch-Token darf nicht über `/api/auth/staff-preview` einlösbar sein und umgekehrt – Test in Task 4.
|
||||
4. **Halbe Sitzung beim Wechsel:** JWT aus halber Sitzung → `/account` 401 → `switch-link` 401, kein Token – Test in Task 4.
|
||||
5. **Abbrechen/Neu-Login mit halber Sitzung im Ticketsystem:** `deleteSessions()` vor dem Neu-Login muss auch mit halber Sitzung klappen (in Appwrite zur MFA-Gruppe gehörend? – wenn nicht, wird der Fehler geschluckt wie heute und die lokale Sitzung trotzdem gelöscht) – Test in Task 5.
|
||||
|
||||
---
|
||||
|
||||
## Teil A – Kundenportal (`kundenbereich.webklar.com/dev-2fa`)
|
||||
|
||||
### Task 1: Appwrite-Sitzung aus dem Login + MFA-Aufrufe im Namen der Sitzung
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/services/appwriteClient.js` (`appwriteFetch`, `loginWithAppwrite`)
|
||||
- Modify: `test/support/appwriteMock.js` (Sessions + MFA-Nachbau)
|
||||
- Test: `test/staffMfa.test.js` (neu, erster Teil)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `loginWithAppwrite(email, password) → { $id, email, name: '', sessionCookie: string }` (`sessionCookie` = Wert von `a_session_<projectId>` aus `set-cookie`, sonst `''`).
|
||||
- `appwriteSessionFetch(path, { method, body, sessionCookie }) → data` – wie `appwriteFetch`, zusätzlich Header `Cookie: a_session_<projectId>=<sessionCookie>` und `X-Appwrite-Locale: de`; Fehler mit `.status`, `.type` wie `appwriteFetch`.
|
||||
- Mock: `POST /v1/account/sessions/email` (setzt `set-cookie a_session_<project>=sess-<userId>`; Nutzer aus `mock.addUser({ $id, email, password, mfa, emailVerification, labels })`), `GET /v1/users/:id` liefert `mfa`, `emailVerification`, `labels`, `email`; `GET /v1/account` mit Cookie → 401 `user_more_factors_required` solange mfa und Faktor fehlt; `POST/PUT /v1/account/mfa/challenges` (OTP abrufbar über `mock.lastOtp(userId)`, `mock.expireChallenges()`); `PATCH /v1/users/:id/mfa`, `PATCH /v1/users/:id/verification`; Zähler `mock.mailsSent(userId)`.
|
||||
|
||||
- [ ] **Step 1:** Worktree anlegen (siehe Kopf), `npm test` im Worktree → alle bestehenden Tests grün (Ausgangslage notieren).
|
||||
- [ ] **Step 2: Failing test** in `test/staffMfa.test.js`:
|
||||
- `loginWithAppwrite liefert sessionCookie` → `assert.equal(user.sessionCookie, 'sess-u_staff')`.
|
||||
- `appwriteSessionFetch schickt Cookie und Locale` → Challenge anlegen, `mock.mailsSent('u_staff') === 1`, Mock hat Header `x-appwrite-locale: de` gesehen.
|
||||
- [ ] **Step 3:** `npm test` → FAIL (`sessionCookie` undefined / Funktion fehlt).
|
||||
- [ ] **Step 4:** Implementieren: `appwriteFetch` gibt intern auch die Response-Header zurück (`response.headers.getSetCookie()`), `loginWithAppwrite` nimmt das Cookie `a_session_${config.appwrite.projectId}`; `appwriteSessionFetch` exportieren. Mock erweitern.
|
||||
- [ ] **Step 5:** `npm test` → PASS (alle).
|
||||
- [ ] **Step 6: Commit** `git add server/services/appwriteClient.js test/support/appwriteMock.js test/staffMfa.test.js && git commit -m "Portal-Login: Appwrite-Sitzung merken, MFA-Aufrufe im Namen der Sitzung"`
|
||||
|
||||
### Task 2: Offene Code-Anmeldungen (`staffMfa.js`)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/services/staffMfa.js`
|
||||
- Test: `test/staffMfa.test.js` (erweitern)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `appwriteSessionFetch` (Task 1).
|
||||
- Produces:
|
||||
- `maskEmail(email: string) → string`
|
||||
- `startStaffMfa({ userId, email, sessionCookie }) → Promise<{ key: string, maskedEmail: string }>` – legt Challenge an, Eintrag `{ userId, email, sessionCookie, challengeId, expiresAt: now+10min, attempts: 0 }` in einer `Map`.
|
||||
- `verifyStaffMfa(key, otp) → Promise<{ ok: true, userId, email } | { ok: false, reason: 'invalid' | 'gone' }>` – `invalid` bei `user_invalid_token` (attempts+1; beim 5. Fehlversuch Eintrag löschen → danach `gone`), `gone` bei unbekanntem/abgelaufenem Schlüssel; bei Erfolg Eintrag löschen. Andere Fehler (z. B. 429) werden geworfen.
|
||||
- `resendStaffMfa(key) → Promise<boolean>` – neue Challenge, `challengeId` ersetzen; `false` wenn Eintrag weg.
|
||||
- `MFA_COOKIE = 'webklar_portal_mfa'`
|
||||
|
||||
- [ ] **Step 1: Failing tests:**
|
||||
- `maskEmail`: `'kenso@webklar.com' → 'k…@webklar.com'`, `'a@b.de' → 'a…@b.de'`, `'' → ''`.
|
||||
- `richtiger Code`: start → `verifyStaffMfa(key, mock.lastOtp('u_staff'))` → `{ ok: true, userId: 'u_staff', email }`; zweiter Aufruf mit gleichem Key → `{ ok:false, reason:'gone' }`.
|
||||
- `falscher Code 5x`: vier Mal `reason:'invalid'`, fünfter `invalid`, sechster `gone`.
|
||||
- `abgelaufen`: Zeit per `Date.now`-Stub +11 min → `gone`.
|
||||
- `resend`: `mock.mailsSent` steigt auf 2, alter OTP ungültig (`invalid`), neuer gültig.
|
||||
- [ ] **Step 2:** `npm test` → FAIL (Modul fehlt).
|
||||
- [ ] **Step 3:** `server/services/staffMfa.js` implementieren (Schlüssel `randomBytes(32).toString('hex')`, Aufräumen wie `staffPreviewTokens.purgeExpired`).
|
||||
- [ ] **Step 4:** `npm test` → PASS.
|
||||
- [ ] **Step 5: Commit** `git add server/services/staffMfa.js test/staffMfa.test.js && git commit -m "Portal: offene Mitarbeiter-Code-Anmeldungen (staffMfa)"`
|
||||
|
||||
### Task 3: Login-Route mit Code-Schritt + Login-Seite
|
||||
|
||||
**Files:**
|
||||
- Create: `server/services/staffAccount.js` (verschiebt `resolveStaffAccount` aus `routes/auth.js` unverändert, exportiert)
|
||||
- Modify: `server/routes/auth.js` (Login, neue Routen, Helfer `startStaffPortalSession`)
|
||||
- Modify: `public/login.html`, `public/app.js` (`initLoginPage`), `public/login.css` (nur falls nötig)
|
||||
- Test: `test/staffLogin.test.js` (neu, mit `startTestApp({ '/api/auth': authRoutes })`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 + 2.
|
||||
- Produces:
|
||||
- `resolveStaffAccount(appwriteUserId, email, account) → { isAdmin, name, email } | null` (aus `server/services/staffAccount.js`).
|
||||
- In `auth.js` lokal: `startStaffPortalSession(res, { userId, staff }) → Promise<void>` (setzt Portal-Sitzung + Preview-Cookie exakt wie heute der Mitarbeiter-Zweig; wird in Task 4 von `staff-switch` genutzt – deshalb exportieren als benannten Export).
|
||||
- `POST /api/auth/login`: Mitarbeiter mit `account.mfa === true` → Status 200 `{ mfaRequired: true, email: maskedEmail }` + signiertes httpOnly-Cookie `webklar_portal_mfa` (10 min, `sameSite: 'lax'`, `secure` wie Portal-Cookie); **keine** Portal-Sitzung.
|
||||
- `POST /api/auth/login/mfa {otp}` → 200 `{ success:true, role:'admin', admin }` | 401 `{ error:'Code falsch oder abgelaufen.' }` | 410 `{ error:'Bitte melde dich erneut an.' }` (+ MFA-Cookie löschen) | 429.
|
||||
- `POST /api/auth/login/mfa/resend` → 200 `{ success:true }` | 410.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (`test/staffLogin.test.js`):
|
||||
- `Kunde ohne MFA: unverändert` → 200, Portal-Cookie gesetzt, kein `mfaRequired`.
|
||||
- `Mitarbeiter ohne MFA: unverändert` → 200 `role:'admin'`, Portal-Cookie gesetzt.
|
||||
- `Mitarbeiter mit MFA: kein Portal-Cookie vor dem Code` → 200 `{ mfaRequired:true, email:'k…@webklar.com' }`; `set-cookie` enthält `webklar_portal_mfa`, **nicht** `webklar_portal_session` und nicht das Preview-Cookie. `GET /api/auth/me` mit diesem Cookie-Stand → `authenticated:false`.
|
||||
- `richtiger Code` → 200 `role:'admin'`, Portal-Cookie gesetzt, MFA-Cookie gelöscht; `/me` → `role:'admin'`.
|
||||
- `falscher Code` → 401 Text wörtlich; nach 5 falschen → 410 `Bitte melde dich erneut an.`.
|
||||
- `ohne MFA-Cookie` → 410.
|
||||
- `resend` → 200, `mock.mailsSent` = 2.
|
||||
- [ ] **Step 2:** `npm test` → FAIL.
|
||||
- [ ] **Step 3:** Server implementieren: `resolveStaffAccount` verschieben; im Login nach `resolveStaffAccount` bei `account?.mfa === true` → `startStaffMfa`, sonst unverändert `startStaffPortalSession`. `staff` für den späteren Erfolg wird bei `verify` neu per `getUserById` + `resolveStaffAccount` ermittelt (nicht im Speicher halten).
|
||||
- [ ] **Step 4:** `npm test` → PASS (alle, inkl. bestehender).
|
||||
- [ ] **Step 5:** Login-Seite: bei `result.mfaRequired` Passwort-Formular ausblenden, Code-Schritt zeigen (Texte aus Global Constraints, Feld `inputmode="numeric" autocomplete="one-time-code" maxlength="6"`), `Abbrechen` = Seite neu laden (`/login.html`). Erfolg → `adminLoginTarget()`.
|
||||
- [ ] **Step 6:** Sichtprüfung mit Playwright gegen den Test-Server aus Step 1 (Mock + App): Passwort → Code-Schritt sichtbar, falscher Code zeigt Text, richtiger Code landet auf `/admin.html`. Screenshot Handy- und Desktop-Breite.
|
||||
- [ ] **Step 7: Commit** `git add server/services/staffAccount.js server/routes/auth.js public/login.html public/app.js test/staffLogin.test.js && git commit -m "Portal-Login: E-Mail-Code für Mitarbeiter mit 2FA"` (+ `public/login.css`, falls geändert)
|
||||
|
||||
### Task 4: Wechsel-Link + Rückweg-Button
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/services/staffPreviewTokens.js`
|
||||
- Create: `server/routes/admin/switchLink.js`, mount in `server/index.js` als `app.use('/api/admin/switch-link', adminSwitchLinkRoutes)` neben `preview-open`
|
||||
- Modify: `server/routes/auth.js` (neue Route `GET /staff-switch`, `staff-preview` prüft `purpose`)
|
||||
- Modify: `public/admin.html` (Button in `.portal-header-actions` vor `#logout-btn`)
|
||||
- Test: `test/staffSwitch.test.js` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolveStaffAccount` (Task 3), `startStaffPortalSession` (Task 3).
|
||||
- Produces:
|
||||
- `createStaffPreviewToken(payload)` unverändert; `consumeStaffPreviewToken(token, purpose = 'preview')` → `null`, wenn `(entry.purpose || 'preview') !== purpose` (Token wird dabei trotzdem verbraucht).
|
||||
- `POST /api/admin/switch-link` (Bearer-JWT): `/account` per JWT (wie `requireAppwriteStaff`, aber ohne Label-Pflicht), dann `getUserById` + `resolveStaffAccount`; kein Mitarbeiter → 403; → 200 `{ openUrl: 'https://<config.preview.baseHost>/api/auth/staff-switch?token=…' }`.
|
||||
- `GET /api/auth/staff-switch?token=` → gültig: `startStaffPortalSession` + `302 /admin.html`; sonst `302 /login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden`.
|
||||
- Button: `<a class="portal-btn-outline" href="https://ticket.webklar.com/tickets">← Ticketsystem</a>`.
|
||||
|
||||
- [ ] **Step 1: Failing tests:**
|
||||
- `switch-link ohne JWT` → 401; `ungültiges JWT` → 401; `JWT aus halber Sitzung` (Mock liefert `user_more_factors_required`) → 401.
|
||||
- `Kunde` (`jwt-kunde`, kein employee) → 403.
|
||||
- `Mitarbeiter ohne admin-Label` (neues Mock-JWT `jwt-staff`, employee-Dokument vorhanden) → 200 mit `openUrl`.
|
||||
- `staff-switch gültig` → 302 `/admin.html`, Portal-Cookie `role:'admin'`, `isAdmin` passend.
|
||||
- `staff-switch zweimal` → zweiter Aufruf 302 auf `/login.html?error=…`.
|
||||
- `staff-switch nach 61 s` → 302 Login.
|
||||
- `Switch-Token über staff-preview` → 410; `Preview-Token über staff-switch` → 302 Login.
|
||||
- `admin.html enthält Rückweg-Link` → statischer Test: Datei enthält `href="https://ticket.webklar.com/tickets"` innerhalb `portal-header-actions`.
|
||||
- [ ] **Step 2:** `npm test` → FAIL.
|
||||
- [ ] **Step 3:** Implementieren.
|
||||
- [ ] **Step 4:** `npm test` → PASS (alle).
|
||||
- [ ] **Step 5: Commit** `git add server/services/staffPreviewTokens.js server/routes/admin/switchLink.js server/index.js server/routes/auth.js public/admin.html test/staffSwitch.test.js && git commit -m "Wechsel Ticketsystem → Projekt-Admin per Einmal-Link, Rückweg-Button"`
|
||||
|
||||
### Task 5 (Portal): Einrichtungs-Skript `scripts/mitarbeiter-2fa.mjs`
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/mitarbeiter-2fa.mjs`, `server/services/staffMfaAdmin.js`
|
||||
- Test: `test/staffMfaAdmin.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`staffMfaAdmin.js`): `listStaffMfaStatus() → Promise<Array<{ userId, name, email, emailVerification, mfa }>>` (alle `employees` mit `userId`); `enableStaffMfa(userId)` (erst verification, dann mfa); `disableStaffMfa(userId)`.
|
||||
- Skript: `--status` (Tabelle), `--enable <userId>|alle` (zeigt vorher die Adressen, fragt `Ja/Nein` auf stdin; `--yes` überspringt), `--disable <userId>`. Läuft per `docker exec -w /app webklar-kundenbereich-server node scripts/mitarbeiter-2fa.mjs …`.
|
||||
|
||||
- [ ] **Step 1: Failing tests:** `listStaffMfaStatus` gegen Mock (2 employees, 1 ohne `userId` → ignoriert); `enableStaffMfa` ruft `verification` **vor** `mfa` (Reihenfolge im Mock protokolliert); `disableStaffMfa` setzt nur `mfa:false`.
|
||||
- [ ] **Step 2:** FAIL → **Step 3:** implementieren → **Step 4:** `npm test` PASS.
|
||||
- [ ] **Step 5: Commit** `git add scripts/mitarbeiter-2fa.mjs server/services/staffMfaAdmin.js test/staffMfaAdmin.test.js && git commit -m "Skript: 2FA für Mitarbeiter anzeigen, einschalten, abschalten"`
|
||||
|
||||
## Teil B – Ticketsystem (`ticket.webklar.com/dev-2fa`)
|
||||
|
||||
### Task 6: MFA-Modul + AuthContext + Login-Seite
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/mfa.js`, `tests/mfa.test.mjs`
|
||||
- Modify: `src/lib/appwrite.js` (`export { client }`), `src/context/AuthContext.jsx`, `src/pages/LoginPage.jsx`, `package.json` (`"test": "node --test tests/"`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`src/lib/mfa.js`, **ohne** Import von `appwrite.js`, damit Node es laden kann):
|
||||
- `createMfaApi(client) → { createEmailChallenge(): Promise<string /*challengeId*/>, completeChallenge(challengeId, otp): Promise<void> }` – über `client.call(method, new URL(client.config.endpoint + '/account/mfa/challenges'), { 'content-type': 'application/json', 'X-Appwrite-Locale': 'de' }, params)`.
|
||||
- `isMoreFactorsError(err) → boolean` (`err?.type === 'user_more_factors_required'`)
|
||||
- `mfaErrorMessage(err) → string` (Texte aus Global Constraints; Fallback `err.message || 'Anmeldung fehlgeschlagen'`)
|
||||
- `maskEmail(email) → string` (gleiche Regel wie Portal)
|
||||
- AuthContext-Wert zusätzlich: `mfaPending: null | { challengeId: string | null, email: string }`, `verifyCode(otp) → {success, error?}`, `sendCode() → {success, error?}` (erstmals oder erneut), `cancelMfa() → Promise<void>`. `login()` liefert bei MFA `{ success:false, mfaRequired:true }`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (`tests/mfa.test.mjs`, Fake-Client zeichnet `call`-Argumente auf):
|
||||
- `createEmailChallenge` → Methode `post`, Pfad endet auf `/account/mfa/challenges`, Params `{ factor:'email' }`, Header `X-Appwrite-Locale: de`, Rückgabe = `$id`.
|
||||
- `completeChallenge('c1','123456')` → Methode `put`, Params `{ challengeId:'c1', otp:'123456' }`.
|
||||
- `isMoreFactorsError` true/false; `mfaErrorMessage` für `user_invalid_token`, Code 429, `user_email_not_verified`, sonstige.
|
||||
- `maskEmail('kenso@webklar.com') === 'k…@webklar.com'`.
|
||||
- [ ] **Step 2:** `npm test` → FAIL.
|
||||
- [ ] **Step 3:** `mfa.js` implementieren → `npm test` PASS.
|
||||
- [ ] **Step 4:** AuthContext:
|
||||
- `login()`: nach `createSessionWithCleanup` → `account.get()`; `isMoreFactorsError` → `sendCode()` + `mfaPending` setzen → `{ success:false, mfaRequired:true }`.
|
||||
- `checkUser()`: `isMoreFactorsError` → `mfaPending = { challengeId: null, email: '' }`, **keine** Mail, `user` bleibt `null`, lokale Sitzung **nicht** löschen (anders als bei anderen 401).
|
||||
- `verifyCode`: `completeChallenge` → `account.get()` → `setUser`, `ensureEmployeeExists`, `mfaPending = null`.
|
||||
- `cancelMfa`: `account.deleteSession('current')` (Fehler schlucken), `clearAllLocalAppwriteSessions()`, `mfaPending = null`.
|
||||
- E-Mail für den Hinweis: beim Login aus dem Formular; beim Reload unbekannt → Hinweis ohne Adresse `Wir schicken dir einen Code an deine E-Mail-Adresse.` bis `Code senden` gedrückt ist.
|
||||
- [ ] **Step 5:** LoginPage: bei `mfaPending` Code-Schritt statt Passwort-Formular (Texte wörtlich; `Code senden` nur wenn `challengeId === null`, sonst Codefeld + `Code erneut senden`); `/login` leitet wie bisher nur bei `user` weiter.
|
||||
- [ ] **Step 6:** UI-Harness (Scratchpad, esbuild + Playwright, Appwrite per `page.route` nachgebaut, Rezept aus Memory): Checks
|
||||
- Login ohne MFA → `/tickets`.
|
||||
- Login mit MFA → Code-Schritt, Hinweis `k…@webklar.com`, **1** Challenge-POST.
|
||||
- falscher Code → `Code falsch oder abgelaufen.`; richtiger Code → `/tickets`.
|
||||
- `Code erneut senden` → 2. Challenge-POST.
|
||||
- Reload mit halber Sitzung → Code-Schritt mit `Code senden`, **0** Challenge-POSTs, keine Weiterleitungsschleife (URL bleibt `/login`, max. 1 `GET /account`).
|
||||
- `Abbrechen` → `DELETE /account/sessions/current`, Passwort-Formular sichtbar; danach neuer Login möglich, auch wenn der Mock `DELETE /account/sessions` mit 401 beantwortet.
|
||||
- 429 beim Challenge-POST → Text `Zu viele Versuche. Bitte warte einige Minuten.`
|
||||
- [ ] **Step 7:** `npx vite build` im Worktree (unter `systemd-run --scope -p MemoryMax=700M -p MemorySwapMax=0`) → Build ok.
|
||||
- [ ] **Step 8: Commit** `git add src/lib/mfa.js tests/mfa.test.mjs src/lib/appwrite.js src/context/AuthContext.jsx src/pages/LoginPage.jsx package.json && git commit -m "Login mit E-Mail-Code (Appwrite-2FA)"`
|
||||
|
||||
### Task 7: Seitenleisten-Eintrag „Projekt-Admin“
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/Navbar.jsx`, `src/lib/employeeAdminApi.js` (oder neue kleine `src/lib/projectAdminSwitch.js` im gleichen Stil)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST /api/admin/switch-link` (Task 4).
|
||||
- Produces: `openProjectAdmin() → Promise<void>` – `account.createJWT()`, POST, `window.location.assign(openUrl)`; bei Fehler `window.location.assign('https://project.webklar.com/login.html')`.
|
||||
|
||||
- [ ] **Step 1:** Eintrag als `<button className="side-link">` (kein `<Link>`, externes Ziel) nach der Linkliste in derselben `side-group`, Icon `IconExternalLink`, Label `Projekt-Admin`, während des Ladens `disabled`; im eingeklappten Zustand `title` gesetzt wie die anderen.
|
||||
- [ ] **Step 2:** UI-Harness-Checks: Eintrag für Mitarbeiter ohne Admin-Rolle sichtbar; Klick → genau 1 POST auf `https://project.webklar.com/api/admin/switch-link` mit `Authorization: Bearer …`, danach Navigation auf `openUrl`; Fehlerantwort 500 → Navigation auf `…/login.html`.
|
||||
- [ ] **Step 3:** `npm test` + `vite build` ok.
|
||||
- [ ] **Step 4: Commit** `git add src/components/Navbar.jsx src/lib/employeeAdminApi.js && git commit -m "Seitenleiste: direkt ins Projekt-Admin wechseln"`
|
||||
|
||||
## Teil C – Livegang (nur mit OK des Nutzers)
|
||||
|
||||
### Task 8: Code-Mail-Vorlage + Übergabe
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-09-30-mfa-mailvorlage.html` (Ticketsystem-Worktree) – Betreff `Dein Anmeldecode für WEBklar`, HTML im Stil der Portal-Vorlagen (`dev-mailvorlagen`/`server/mail-templates`), Platzhalter `{{otp}}`, Text `Der Code gilt 1 Stunde.` und `Nicht du? Dann ändere bitte dein Passwort.`
|
||||
|
||||
- [ ] **Step 1:** Vorlage erstellen, lokal mit `{{otp}} = 123456` rendern, Screenshot an den Nutzer.
|
||||
- [ ] **Step 2:** Übergabe an den Nutzer mit genauer Reihenfolge:
|
||||
1. Portal: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-2fa push origin feature/mitarbeiter-2fa:main` – danach Live-Check: `POST /api/admin/switch-link` ohne JWT → 401, Login-Seite lädt.
|
||||
2. Ticketsystem: `git -C /home/webklar/apps/ticket.webklar.com/dev-2fa push origin feature/mitarbeiter-2fa:test` – danach Live-Check Bundle enthält `Projekt-Admin`.
|
||||
3. Nutzer hinterlegt Vorlage in der Appwrite-Console (Auth → Templates → MFA-Challenge, Sprache Deutsch).
|
||||
4. `--status`, dann `--enable <Kensos userId>`, Nutzer probiert beide Logins + Wechsel, dann `--enable alle`.
|
||||
- [ ] **Step 3:** Commit der Vorlage (`git add docs/superpowers/specs/2026-09-30-mfa-mailvorlage.html`).
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
Appwrite-Vorlage "MFA-Challenge" (Auth → Templates → Sprache Deutsch)
|
||||
Betreff: Dein Anmeldecode für WEBklar
|
||||
Platzhalter (Appwrite): {{otp}}, {{user}}
|
||||
Code gilt 1 Stunde (Appwrite fest).
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Dein Anmeldecode für WEBklar</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Dein Code: {{otp}} ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Anmeldung</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Anmeldecode</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{user}}, hier ist dein Code</td></tr>
|
||||
<tr><td class="px ink" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;color:#111111;">Gib ihn im Ticketsystem oder im Projekt-Admin ein, um die Anmeldung abzuschließen.</td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td align="center" class="ink" style="padding:22px;font-family:'Courier New',Courier,monospace;font-size:36px;line-height:40px;font-weight:bold;letter-spacing:10px;color:#111111;">{{otp}}</td></tr></table></td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Der Code gilt 1 Stunde. Nicht du? Dann ändere bitte dein Passwort.</td></tr>
|
||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Nachricht von WEBklar · Bitte nicht antworten.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
# Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem ⇄ Projekt-Admin
|
||||
|
||||
Stand: 2026-09-30 · Status: Design vom Nutzer freigegeben (Teil 1 + Teil 2), Spec wartet auf Review
|
||||
|
||||
## Ziel
|
||||
|
||||
1. Jeder Mitarbeiter-Login (Ticketsystem ticket.webklar.com **und** Projekt-Admin project.webklar.com)
|
||||
verlangt nach dem Passwort einen 6-stelligen Code, der per E-Mail kommt. Pflicht für alle
|
||||
Mitarbeiter. Kunden bleiben unverändert.
|
||||
2. Aus der Seitenleiste des Ticketsystems springt man ohne zweiten Login ins Projekt-Admin; aus dem
|
||||
Projekt-Admin führt ein Button zurück ins Ticketsystem.
|
||||
|
||||
Entscheidungen des Nutzers: E-Mail-Code statt TOTP/KeePass; 2FA über Appwrite selbst (Variante A),
|
||||
nicht als reine Oberflächen-Sperre; Pflicht für alle Mitarbeiter.
|
||||
|
||||
## Ausgangslage (geprüft)
|
||||
|
||||
- Beide Systeme nutzen dieselben Appwrite-Konten (Appwrite **1.8.1**, Projekt `6a1058610003c5a13a05`).
|
||||
- Appwrite verschickt bereits Mails: `_APP_SMTP_HOST=mail.your-server.de:587`, Absender
|
||||
`WEBklar <noreply@webklar.com>`.
|
||||
- Appwrite-MFA (1.8.1): Ist `user.mfa = true` und die E-Mail bestätigt (`emailVerification = true`),
|
||||
braucht jede Sitzung 2 Faktoren. Eine Passwort-Sitzung hat nur den Faktor `password`; jeder
|
||||
Nicht-MFA-Aufruf liefert dann `401 user_more_factors_required`. Das gilt auch für **bereits
|
||||
bestehende** Sitzungen und für JWTs aus solchen Sitzungen.
|
||||
- `POST /v1/account/mfa/challenges` `{factor:'email'}` → `{ $id, expire }`, schickt die Mail
|
||||
(Vorlage `mfaChallenge`). Code gilt **1 Stunde**. Limit 10 Anfragen pro Nutzer und Zeitfenster.
|
||||
- `PUT /v1/account/mfa/challenges` `{challengeId, otp}` → Sitzung bekommt Faktor `email`.
|
||||
Falscher/abgelaufener Code: `401 user_invalid_token`.
|
||||
- Server-API mit Key: `PATCH /v1/users/:id/mfa {mfa}`, `PATCH /v1/users/:id/verification {emailVerification}`.
|
||||
- Ticketsystem: Appwrite-Web-SDK **13.0.2** (kennt kein MFA). AuthContext meldet per
|
||||
`createEmailPasswordSession` an und prüft mit `account.get()`.
|
||||
- Projekt-Admin: `POST /api/auth/login` ruft serverseitig `/account/sessions/email` ohne API-Key auf,
|
||||
nimmt nur die `userId` und setzt die Portal-Sitzung (`role: 'admin'`) für Mitarbeiter
|
||||
(`resolveStaffAccount`). **Mit MFA wäre das eine Umgehung**, weil die halbe Sitzung reicht.
|
||||
- `requireAppwriteStaff` (JWT-Prüfung für `/api/admin/*` aus dem Ticketsystem) verlangt das
|
||||
Label `admin`. Mitarbeiter ohne Admin-Label kommen per Portal-Login trotzdem ins Projekt-Admin
|
||||
(`staff.isAdmin = false`).
|
||||
- Einmal-Link-Mechanismus existiert: `services/staffPreviewTokens.js` (Map im Speicher, 60 s, einmalig),
|
||||
genutzt von `/api/admin/preview-open` → `/api/auth/staff-preview`.
|
||||
- Projekt-Admin (`public/admin.html`) hat keine Seitenleiste, sondern Kopfzeile + Tabs.
|
||||
|
||||
## Teil 1: Anmeldung mit E-Mail-Code
|
||||
|
||||
### Ablauf für Mitarbeiter
|
||||
|
||||
1. E-Mail + Passwort.
|
||||
2. Anzeige „Wir haben dir einen Code an k…@webklar.com geschickt“ + 6-stelliges Codefeld
|
||||
(`inputmode="numeric"`, `autocomplete="one-time-code"`), Link „Code erneut senden“, Link „Abbrechen“
|
||||
(meldet die halbe Sitzung ab, zurück zur Passwort-Eingabe).
|
||||
3. Richtiger Code → angemeldet. Falscher/abgelaufener Code → „Code falsch oder abgelaufen“, Feld bleibt.
|
||||
|
||||
### Ticketsystem (Repo JUSN/tickte-system)
|
||||
|
||||
- **Kein SDK-Upgrade.** Neues Modul `src/lib/mfa.js` mit zwei Funktionen über `client.call(...)` des
|
||||
vorhandenen SDK-Clients (nutzt dieselbe Sitzung/Cookie-Fallback wie das SDK):
|
||||
`createEmailChallenge()` → `challengeId`; `completeChallenge(challengeId, otp)`.
|
||||
Beide senden `X-Appwrite-Locale: de`, damit die deutsche Vorlage greift.
|
||||
- `AuthContext`:
|
||||
- neuer Zustand `mfaPending` (`{ challengeId, email }` oder `null`).
|
||||
- `login()`: nach der Passwort-Sitzung `account.get()`; bei `user_more_factors_required` →
|
||||
Challenge anlegen, `mfaPending` setzen, `{ success: false, mfaRequired: true }` zurück.
|
||||
- `checkUser()` beim Laden: gleicher Fall (bestehende halbe Sitzung, z. B. direkt nach dem
|
||||
Einschalten) → **keine** neue Mail automatisch, sondern Code-Schritt mit Knopf „Code senden“.
|
||||
Grund: jedes Neuladen würde sonst eine Mail auslösen und das Limit (10) aufbrauchen.
|
||||
- `verifyCode(otp)`, `resendCode()`, `cancelMfa()` (= `deleteSession('current')`).
|
||||
- Nach erfolgreichem Code wie bisher `account.get()` + `ensureEmployeeExists`.
|
||||
- `LoginPage.jsx`: zweiter Schritt „Code eingeben“; Routing: solange `mfaPending`, führt jede
|
||||
geschützte Route auf `/login`.
|
||||
- Fehlertexte: `user_invalid_token` → „Code falsch oder abgelaufen“; `429`/`general_rate_limit_exceeded`
|
||||
→ „Zu viele Versuche, bitte einige Minuten warten“; `user_email_not_verified` →
|
||||
„2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben“.
|
||||
|
||||
### Projekt-Admin (Repo Kundenportal, project.webklar.com)
|
||||
|
||||
- `loginWithAppwrite` liest zusätzlich die Sitzung aus der Antwort (`set-cookie`
|
||||
`a_session_<projectId>` via `headers.getSetCookie()`), damit der Server im Namen dieser Sitzung
|
||||
weiterarbeiten kann.
|
||||
- `POST /api/auth/login` für **Mitarbeiter**: Ist `account.mfa === true` (aus `getUserById`), wird
|
||||
**keine** Portal-Sitzung gesetzt. Stattdessen:
|
||||
- Challenge über die Sitzung anlegen, Eintrag in einer Map im Speicher
|
||||
`{ userId, email, appwriteSessionCookie, challengeId, expiresAt: +10 min, attempts: 0 }`
|
||||
unter zufälligem Schlüssel; Schlüssel als signiertes, httpOnly-Cookie `webklar_portal_mfa`.
|
||||
- Antwort `{ mfaRequired: true, email: <maskiert> }`.
|
||||
- `POST /api/auth/login/mfa` `{ otp }`: prüft den Code bei Appwrite (`PUT /account/mfa/challenges`
|
||||
mit der gespeicherten Sitzung). Erfolg → exakt der bisherige Mitarbeiter-Zweig (Portal-Sitzung +
|
||||
Preview-Cookie), Map-Eintrag + Cookie löschen. Max. 5 Fehlversuche pro Eintrag, dann Eintrag weg
|
||||
→ neu anmelden.
|
||||
- `POST /api/auth/login/mfa/resend`: neue Challenge für denselben Eintrag.
|
||||
- Serverneustart verwirft offene Einträge → Nutzer meldet sich neu an (akzeptiert).
|
||||
- Kunden-Zweig bleibt unverändert (Kunden haben `mfa = false`).
|
||||
- `public/login.html` + `app.js`: zweiter Schritt mit Codefeld, gleiche Texte wie im Ticketsystem.
|
||||
|
||||
### Einmalige Einrichtung (Skript, nach dem Livegang beider Teile)
|
||||
|
||||
- Skript `scripts/mitarbeiter-2fa.mjs` im Kundenportal-Repo (läuft im Container mit dem Server-Key):
|
||||
- `--status`: listet alle Mitarbeiter (Collection `employees` → `userId`) mit `emailVerification`
|
||||
und `mfa`.
|
||||
- `--enable [userId|alle]`: setzt `emailVerification = true`, dann `mfa = true`.
|
||||
- `--disable <userId>`: Notfall-Schalter, wenn bei einer Person keine Mail ankommt.
|
||||
- Vor dem Einschalten wird die E-Mail-Adresse jedes Kontos angezeigt; nur Adressen, die
|
||||
tatsächlich Post empfangen, werden bestätigt.
|
||||
- Wirkung: Alle bestehenden Mitarbeiter-Sitzungen fragen beim nächsten Aufruf nach dem Code.
|
||||
- Keine Wiederherstellungscodes (Faktor ist das Postfach; Notfall = `--disable`).
|
||||
|
||||
### Code-Mail
|
||||
|
||||
- Appwrite-Vorlage `mfaChallenge`, Sprache `de`, als eigene Vorlage im Projekt hinterlegt
|
||||
(Betreff „Dein Anmeldecode für WEBklar“, Inhalt im Stil der WEBklar-Mailvorlagen: Logo, Code groß,
|
||||
„gilt 1 Stunde“, „Nicht du? Dann ändere bitte dein Passwort.“).
|
||||
- Wird über die Appwrite-Console gesetzt (Auth → Templates) – der Nutzer ist dort eingeloggt; ich
|
||||
bereite HTML und Betreff vor.
|
||||
|
||||
## Teil 2: Wechsel zwischen Ticketsystem und Projekt-Admin
|
||||
|
||||
### Hinweg (Ticketsystem → Projekt-Admin, ohne zweiten Login)
|
||||
|
||||
- `Navbar.jsx`: neuer Eintrag **„Projekt-Admin“** mit Symbol `IconExternalLink` nach „Finanzen“ /
|
||||
„Admin“, für alle Mitarbeiter sichtbar. Klick → `openProjectAdmin()` → `window.location.assign(openUrl)`
|
||||
(selber Tab). Während des Ladens deaktiviert; bei Fehler Fallback auf
|
||||
`https://project.webklar.com/login.html`.
|
||||
- Kundenportal: `POST /api/admin/switch-link` (Bearer-JWT aus dem Ticketsystem):
|
||||
- eigene Prüfung statt `requireAppwriteStaff`: JWT gültig (`/account`) **und** `resolveStaffAccount`
|
||||
findet einen Mitarbeiter – so kommen auch Mitarbeiter ohne Admin-Label rüber, wie beim normalen
|
||||
Login.
|
||||
- Antwort `{ openUrl: 'https://project.webklar.com/api/auth/staff-switch?token=…' }`, Token über
|
||||
`createStaffPreviewToken` (60 s, einmalig) mit `{ userId, email, name, isAdmin, purpose: 'switch' }`.
|
||||
- `GET /api/auth/staff-switch?token=…`: `consumeStaffPreviewToken`; nur `purpose === 'switch'`
|
||||
akzeptiert (und `staff-preview` akzeptiert umgekehrt keine Switch-Tokens). Setzt Portal-Sitzung +
|
||||
Preview-Cookie wie der Mitarbeiter-Login, Redirect `/admin.html`. Ungültig/abgelaufen → Redirect
|
||||
`/login.html?error=Link abgelaufen, bitte anmelden`.
|
||||
- 2FA bleibt gewahrt: Das JWT stammt aus einer Sitzung, die nach Teil 1 nur mit Code voll gültig ist.
|
||||
|
||||
### Rückweg (Projekt-Admin → Ticketsystem)
|
||||
|
||||
- `admin.html` Kopfzeile: Button **„← Ticketsystem“** (Link `https://ticket.webklar.com/tickets`)
|
||||
vor „Abmelden“. Nur in der Admin-Ansicht; im Modus „Als Kunde ansehen“ und bei Kunden nicht.
|
||||
- Kein automatischer Login in diese Richtung: Ist die Ticketsystem-Sitzung noch aktiv, ist man direkt
|
||||
drin, sonst Login mit Code.
|
||||
|
||||
## Tests
|
||||
|
||||
Mit nachgebautem Appwrite (Mock nach 1.8.1-Verhalten, keine echten Daten):
|
||||
|
||||
- Ticketsystem: Login ohne MFA unverändert; Login mit MFA → Code-Schritt; richtiger Code;
|
||||
falscher Code; abgelaufener Code; erneut senden; Abbrechen; halbe Sitzung beim Neuladen (keine
|
||||
automatische Mail); 429-Text; Seitenleisten-Eintrag öffnet `openUrl`, Fehler → Fallback.
|
||||
- Projekt-Admin: Kunden-Login unverändert; Mitarbeiter ohne MFA unverändert; Mitarbeiter mit MFA
|
||||
bekommt **keine** Portal-Sitzung vor dem Code; richtiger/falscher Code; 5 Fehlversuche; resend;
|
||||
switch-link ohne/mit ungültigem JWT (401), mit halber Sitzung (401), Kunde (403), Mitarbeiter ohne
|
||||
Admin-Label (200); staff-switch einmalig, abgelaufen, falscher `purpose`; Rückweg-Button nur für
|
||||
Mitarbeiter und nicht im „Als Kunde ansehen“-Modus.
|
||||
- Bestehende Testsuites beider Repos grün.
|
||||
|
||||
## Livegang (Reihenfolge)
|
||||
|
||||
1. Kundenportal live (Push nach `main` durch den Nutzer bzw. auf sein OK).
|
||||
2. Ticketsystem live (Push nach `test` durch den Nutzer).
|
||||
3. Code-Mail-Vorlage in der Appwrite-Console hinterlegen.
|
||||
4. `mitarbeiter-2fa.mjs --status`, dann zuerst nur für Kenso `--enable`, einmal in beiden Systemen
|
||||
durchspielen, dann `--enable alle`.
|
||||
|
||||
Ohne Schritt 4 ändert sich für niemanden etwas (MFA aus → Login wie bisher); Teil 2 funktioniert
|
||||
schon nach Schritt 1+2.
|
||||
|
||||
## Nicht enthalten
|
||||
|
||||
- TOTP/KeePass, SMS, Wiederherstellungscodes, 2FA für Kunden.
|
||||
- Automatischer Login vom Projekt-Admin zurück ins Ticketsystem.
|
||||
- SDK-Upgrade des Ticketsystems.
|
||||
@@ -13,13 +13,21 @@ import {
|
||||
IconLogout,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconExternalLink,
|
||||
} from '@tabler/icons-react'
|
||||
import { openProjectAdmin } from '../lib/employeeAdminApi'
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
|
||||
const handleProjectAdmin = async () => {
|
||||
setSwitching(true)
|
||||
await openProjectAdmin()
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
@@ -64,6 +72,16 @@ export default function Sidebar() {
|
||||
<span className="side-link-label">{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="side-link side-link-button"
|
||||
onClick={handleProjectAdmin}
|
||||
disabled={switching}
|
||||
title="Projekt-Admin"
|
||||
>
|
||||
<IconExternalLink />
|
||||
<span className="side-link-label">Projekt-Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
Query,
|
||||
hasAppwriteSession,
|
||||
projectId as PROJECT_ID,
|
||||
client,
|
||||
} from '../lib/appwrite'
|
||||
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
|
||||
|
||||
const AuthContext = createContext()
|
||||
const mfa = createMfaApi(client)
|
||||
|
||||
function clearStaleAppwriteSessions() {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
@@ -89,6 +92,9 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [employee, setEmployee] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 2FA: Passwort stimmt, Sitzung braucht noch den Code aus der E-Mail.
|
||||
// challengeId null = noch keine Mail verschickt (z. B. nach dem Neuladen)
|
||||
const [mfaPending, setMfaPending] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkUser()
|
||||
@@ -106,6 +112,12 @@ export function AuthProvider({ children }) {
|
||||
setUser(session)
|
||||
setEmployee(await ensureEmployeeExists(session))
|
||||
} catch (error) {
|
||||
if (isMoreFactorsError(error)) {
|
||||
// Halbe Sitzung: Code-Schritt zeigen, aber keine Mail von selbst (jedes Neuladen wuerde sonst eine ausloesen)
|
||||
setMfaPending({ challengeId: null, email: '' })
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
if (error.code !== 401 && error.code !== 404) {
|
||||
console.error('Unexpected error checking user:', error)
|
||||
}
|
||||
@@ -126,7 +138,15 @@ export function AuthProvider({ children }) {
|
||||
|
||||
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
||||
|
||||
const session = await account.get()
|
||||
let session
|
||||
try {
|
||||
session = await account.get()
|
||||
} catch (error) {
|
||||
if (!isMoreFactorsError(error)) throw error
|
||||
setMfaPending({ challengeId: null, email: maskEmail(normalizedEmail) })
|
||||
const sent = await sendCode()
|
||||
return { success: false, mfaRequired: true, error: sent.error }
|
||||
}
|
||||
setUser(session)
|
||||
setEmployee(await ensureEmployeeExists(session))
|
||||
|
||||
@@ -156,6 +176,38 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
try {
|
||||
const challengeId = await mfa.createEmailChallenge()
|
||||
setMfaPending((pending) => ({ email: '', ...pending, challengeId }))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: mfaErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode(otp) {
|
||||
if (!mfaPending?.challengeId) return { success: false, error: 'Bitte zuerst einen Code anfordern.' }
|
||||
try {
|
||||
await mfa.completeChallenge(mfaPending.challengeId, String(otp || '').trim())
|
||||
const session = await account.get()
|
||||
setUser(session)
|
||||
setEmployee(await ensureEmployeeExists(session))
|
||||
setMfaPending(null)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: mfaErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelMfa() {
|
||||
try {
|
||||
await account.deleteSession('current')
|
||||
} catch { /* Sitzung ggf. schon weg */ }
|
||||
clearAllLocalAppwriteSessions()
|
||||
setMfaPending(null)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await account.deleteSessions()
|
||||
@@ -165,6 +217,7 @@ export function AuthProvider({ children }) {
|
||||
clearAllLocalAppwriteSessions()
|
||||
setUser(null)
|
||||
setEmployee(null)
|
||||
setMfaPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +229,11 @@ export function AuthProvider({ children }) {
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAdmin
|
||||
isAdmin,
|
||||
mfaPending,
|
||||
sendCode,
|
||||
verifyCode,
|
||||
cancelMfa,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ if (!projectId) {
|
||||
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
|
||||
}
|
||||
|
||||
const client = new Client().setEndpoint(endpoint).setProject(projectId)
|
||||
export const client = new Client().setEndpoint(endpoint).setProject(projectId)
|
||||
|
||||
export const account = new Account(client)
|
||||
export const databases = new Databases(client)
|
||||
|
||||
@@ -38,3 +38,13 @@ export function updateEmployeeWithLogin(employeeId, payload) {
|
||||
export function deleteEmployeeWithLogin(employeeId) {
|
||||
return adminFetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Wechsel ins Projekt-Admin ohne zweiten Login: Einmal-Link holen und im selben Tab oeffnen. */
|
||||
export async function openProjectAdmin() {
|
||||
try {
|
||||
const { openUrl } = await adminFetch('/api/admin/switch-link', { method: 'POST', body: {} })
|
||||
window.location.assign(openUrl)
|
||||
} catch {
|
||||
window.location.assign('https://project.webklar.com/login.html')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Mitarbeiter-2FA per E-Mail-Code (Appwrite 1.8.1). Das SDK 13 kennt die MFA-Endpunkte nicht, deshalb
|
||||
* gehen die Aufrufe ueber client.call - mit derselben Sitzung (Cookie-Fallback) wie das SDK.
|
||||
* Bewusst ohne Import von appwrite.js, damit Node dieses Modul in den Tests laden kann.
|
||||
*/
|
||||
const HEADERS = { 'content-type': 'application/json', 'X-Appwrite-Locale': 'de' }
|
||||
|
||||
export function createMfaApi(client) {
|
||||
const url = () => new URL(`${client.config.endpoint}/account/mfa/challenges`)
|
||||
return {
|
||||
async createEmailChallenge() {
|
||||
const challenge = await client.call('post', url(), { ...HEADERS }, { factor: 'email' })
|
||||
return challenge.$id
|
||||
},
|
||||
async completeChallenge(challengeId, otp) {
|
||||
await client.call('put', url(), { ...HEADERS }, { challengeId, otp })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isMoreFactorsError(err) {
|
||||
return err?.type === 'user_more_factors_required'
|
||||
}
|
||||
|
||||
export function mfaErrorMessage(err) {
|
||||
if (err?.type === 'user_invalid_token') return 'Code falsch oder abgelaufen.'
|
||||
if (err?.code === 429 || err?.type === 'general_rate_limit_exceeded') return 'Zu viele Versuche. Bitte warte einige Minuten.'
|
||||
if (err?.type === 'user_email_not_verified') return '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.'
|
||||
return err?.message || 'Anmeldung fehlgeschlagen'
|
||||
}
|
||||
|
||||
/** kenso@webklar.com -> k…@webklar.com */
|
||||
export function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@')
|
||||
if (!local || !domain) return ''
|
||||
return `${local[0]}…@${domain}`
|
||||
}
|
||||
+103
-4
@@ -8,8 +8,10 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { login } = useAuth()
|
||||
const [code, setCode] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -22,6 +24,8 @@ export default function LoginPage() {
|
||||
|
||||
if (result.success) {
|
||||
navigate('/tickets')
|
||||
} else if (result.mfaRequired) {
|
||||
if (result.error) setError(result.error)
|
||||
} else {
|
||||
// Bessere Fehlermeldungen
|
||||
let errorMessage = result.error || 'Login fehlgeschlagen'
|
||||
@@ -41,6 +45,101 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCode = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const result = await verifyCode(code)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
navigate('/tickets')
|
||||
} else {
|
||||
setError(result.error)
|
||||
setCode('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
setError('')
|
||||
setNotice('')
|
||||
setLoading(true)
|
||||
const result = await sendCode()
|
||||
setLoading(false)
|
||||
if (result.success) setNotice('Neuer Code ist unterwegs.')
|
||||
else setError(result.error)
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
await cancelMfa()
|
||||
setError('')
|
||||
setNotice('')
|
||||
setCode('')
|
||||
setPassword('')
|
||||
}
|
||||
|
||||
const linkButton = {
|
||||
background: 'none',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
color: '#e2e8f0',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}
|
||||
|
||||
const codeStep = mfaPending && (
|
||||
<form onSubmit={handleCode}>
|
||||
{error && (
|
||||
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{!mfaPending.challengeId
|
||||
? 'Wir schicken dir einen Code an deine E-Mail-Adresse.'
|
||||
: `Wir haben dir einen Code an ${mfaPending.email || 'deine E-Mail-Adresse'} geschickt.`}
|
||||
</p>
|
||||
{notice && <p style={{ marginTop: 0 }}>{notice}</p>}
|
||||
{mfaPending.challengeId ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="mfa-code">Code aus der E-Mail</label>
|
||||
<input
|
||||
id="mfa-code"
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
style={{ letterSpacing: '0.3em' }}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-green" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Prüfe…' : 'Anmelden'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||
<button type="button" style={linkButton} onClick={handleSend} disabled={loading}>Code erneut senden</button>
|
||||
{' · '}
|
||||
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn btn-green" style={{ width: '100%' }} onClick={handleSend} disabled={loading}>
|
||||
{loading ? 'Sende…' : 'Code senden'}
|
||||
</button>
|
||||
<p style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
@@ -95,7 +194,7 @@ export default function LoginPage() {
|
||||
<h2>Webklar WOMS 2.0</h2>
|
||||
</div>
|
||||
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{codeStep || <form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
@@ -135,7 +234,7 @@ export default function LoginPage() {
|
||||
>
|
||||
{loading ? 'Login läuft...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</form>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -843,6 +843,11 @@ textarea.form-control {
|
||||
}
|
||||
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
||||
.side-link-button {
|
||||
background: transparent; border: none; font: inherit; cursor: pointer;
|
||||
width: calc(100% - 2 * var(--sp-1)); text-align: left;
|
||||
}
|
||||
.side-link-button:disabled { opacity: 0.6; cursor: progress; }
|
||||
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.side-link.active svg { color: var(--accent); }
|
||||
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createMfaApi, isMoreFactorsError, mfaErrorMessage, maskEmail } from '../src/lib/mfa.js'
|
||||
|
||||
// Fake-Client wie Appwrite-SDK 13: call(method, url, headers, params)
|
||||
function fakeClient(response = {}) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
config: { endpoint: 'https://ticket.webklar.com/v1' },
|
||||
call: async (method, url, headers, params) => {
|
||||
calls.push({ method, url: String(url), headers, params })
|
||||
return response
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('createEmailChallenge: POST mit factor email und deutscher Sprache', async () => {
|
||||
const client = fakeClient({ $id: 'ch1' })
|
||||
assert.equal(await createMfaApi(client).createEmailChallenge(), 'ch1')
|
||||
const [call] = client.calls
|
||||
assert.equal(call.method, 'post')
|
||||
assert.equal(call.url, 'https://ticket.webklar.com/v1/account/mfa/challenges')
|
||||
assert.deepEqual(call.params, { factor: 'email' })
|
||||
assert.equal(call.headers['X-Appwrite-Locale'], 'de')
|
||||
})
|
||||
|
||||
test('completeChallenge: PUT mit challengeId und Code', async () => {
|
||||
const client = fakeClient({})
|
||||
await createMfaApi(client).completeChallenge('c1', '123456')
|
||||
assert.equal(client.calls[0].method, 'put')
|
||||
assert.deepEqual(client.calls[0].params, { challengeId: 'c1', otp: '123456' })
|
||||
})
|
||||
|
||||
test('isMoreFactorsError', () => {
|
||||
assert.equal(isMoreFactorsError({ type: 'user_more_factors_required' }), true)
|
||||
assert.equal(isMoreFactorsError({ type: 'user_unauthorized', code: 401 }), false)
|
||||
assert.equal(isMoreFactorsError(null), false)
|
||||
})
|
||||
|
||||
test('mfaErrorMessage: feste Texte', () => {
|
||||
assert.equal(mfaErrorMessage({ type: 'user_invalid_token' }), 'Code falsch oder abgelaufen.')
|
||||
assert.equal(mfaErrorMessage({ code: 429 }), 'Zu viele Versuche. Bitte warte einige Minuten.')
|
||||
assert.equal(mfaErrorMessage({ type: 'general_rate_limit_exceeded' }), 'Zu viele Versuche. Bitte warte einige Minuten.')
|
||||
assert.equal(mfaErrorMessage({ type: 'user_email_not_verified' }), '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
|
||||
assert.equal(mfaErrorMessage({ message: 'Netzwerk weg' }), 'Netzwerk weg')
|
||||
assert.equal(mfaErrorMessage({}), 'Anmeldung fehlgeschlagen')
|
||||
})
|
||||
|
||||
test('maskEmail', () => {
|
||||
assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com')
|
||||
assert.equal(maskEmail(''), '')
|
||||
})
|
||||
Reference in new issue
Block a user