Compare commits

...
5 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 82c2460835 Code-Mail-Vorlage für Appwrite (MFA-Challenge, Deutsch)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 dddfb02d1d Seitenleiste: direkt ins Projekt-Admin wechseln
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 b8e4de97c7 Login mit E-Mail-Code (Appwrite-2FA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 21325c4037 Plan: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 3af8a2f2ad Spec: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
11 changed files with 741 additions and 7 deletions

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}}&#8199;&#847;&#8199;&#847;&#8199;&#847;</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;">&nbsp;</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.
+18
View File
@@ -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>
+59 -2
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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')
}
}
+37
View File
@@ -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
View File
@@ -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>
+5
View File
@@ -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); }
+53
View File
@@ -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(''), '')
})