Compare commits
5
Commits
3a3e43d5ec
...
82c2460835
| 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,
|
IconLogout,
|
||||||
IconLayoutSidebarLeftCollapse,
|
IconLayoutSidebarLeftCollapse,
|
||||||
IconLayoutSidebarLeftExpand,
|
IconLayoutSidebarLeftExpand,
|
||||||
|
IconExternalLink,
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
|
import { openProjectAdmin } from '../lib/employeeAdminApi'
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { user, logout, isAdmin } = useAuth()
|
const { user, logout, isAdmin } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [switching, setSwitching] = useState(false)
|
||||||
|
|
||||||
|
const handleProjectAdmin = async () => {
|
||||||
|
setSwitching(true)
|
||||||
|
await openProjectAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -64,6 +72,16 @@ export default function Sidebar() {
|
|||||||
<span className="side-link-label">{link.label}</span>
|
<span className="side-link-label">{link.label}</span>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
hasAppwriteSession,
|
hasAppwriteSession,
|
||||||
projectId as PROJECT_ID,
|
projectId as PROJECT_ID,
|
||||||
|
client,
|
||||||
} from '../lib/appwrite'
|
} from '../lib/appwrite'
|
||||||
|
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
|
||||||
|
|
||||||
const AuthContext = createContext()
|
const AuthContext = createContext()
|
||||||
|
const mfa = createMfaApi(client)
|
||||||
|
|
||||||
function clearStaleAppwriteSessions() {
|
function clearStaleAppwriteSessions() {
|
||||||
if (typeof window === 'undefined' || !window.localStorage) return
|
if (typeof window === 'undefined' || !window.localStorage) return
|
||||||
@@ -89,6 +92,9 @@ export function AuthProvider({ children }) {
|
|||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [employee, setEmployee] = useState(null)
|
const [employee, setEmployee] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
checkUser()
|
checkUser()
|
||||||
@@ -106,6 +112,12 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(session)
|
setUser(session)
|
||||||
setEmployee(await ensureEmployeeExists(session))
|
setEmployee(await ensureEmployeeExists(session))
|
||||||
} catch (error) {
|
} 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) {
|
if (error.code !== 401 && error.code !== 404) {
|
||||||
console.error('Unexpected error checking user:', error)
|
console.error('Unexpected error checking user:', error)
|
||||||
}
|
}
|
||||||
@@ -126,7 +138,15 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
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)
|
setUser(session)
|
||||||
setEmployee(await ensureEmployeeExists(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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await account.deleteSessions()
|
await account.deleteSessions()
|
||||||
@@ -165,6 +217,7 @@ export function AuthProvider({ children }) {
|
|||||||
clearAllLocalAppwriteSessions()
|
clearAllLocalAppwriteSessions()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setEmployee(null)
|
setEmployee(null)
|
||||||
|
setMfaPending(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +229,11 @@ export function AuthProvider({ children }) {
|
|||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
isAdmin
|
isAdmin,
|
||||||
|
mfaPending,
|
||||||
|
sendCode,
|
||||||
|
verifyCode,
|
||||||
|
cancelMfa,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ if (!projectId) {
|
|||||||
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
|
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 account = new Account(client)
|
||||||
export const databases = new Databases(client)
|
export const databases = new Databases(client)
|
||||||
|
|||||||
@@ -38,3 +38,13 @@ export function updateEmployeeWithLogin(employeeId, payload) {
|
|||||||
export function deleteEmployeeWithLogin(employeeId) {
|
export function deleteEmployeeWithLogin(employeeId) {
|
||||||
return adminFetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE' })
|
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 [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
const { login } = useAuth()
|
const [notice, setNotice] = useState('')
|
||||||
|
|
||||||
|
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
@@ -22,6 +24,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/tickets')
|
navigate('/tickets')
|
||||||
|
} else if (result.mfaRequired) {
|
||||||
|
if (result.error) setError(result.error)
|
||||||
} else {
|
} else {
|
||||||
// Bessere Fehlermeldungen
|
// Bessere Fehlermeldungen
|
||||||
let errorMessage = result.error || 'Login fehlgeschlagen'
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -95,7 +194,7 @@ export default function LoginPage() {
|
|||||||
<h2>Webklar WOMS 2.0</h2>
|
<h2>Webklar WOMS 2.0</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
||||||
<form onSubmit={handleSubmit}>
|
{codeStep || <form onSubmit={handleSubmit}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
{error}
|
{error}
|
||||||
@@ -135,7 +234,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{loading ? 'Login läuft...' : 'Login'}
|
{loading ? 'Login läuft...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -843,6 +843,11 @@ textarea.form-control {
|
|||||||
}
|
}
|
||||||
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
.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 { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
.side-link.active svg { color: var(--accent); }
|
.side-link.active svg { color: var(--accent); }
|
||||||
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
.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