From 5635598a58fb64091075a571d028a063945638bd Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 12 May 2026 17:00:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana):=20migrate=20to=20central=20auth=20p?= =?UTF-8?q?ortal=20=E2=80=94=20no=20embedded=20login=20UI,=20clean=20cut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit managarten redet jetzt nicht mehr direkt mit Better-Auth — Login, Register, Passwort-Reset, 2FA-Verify, Magic-Link, Passkey-Login laufen ALLE über `auth.mana.how` (mana-auth-web portal). managarten ist nur noch Consumer einer existierenden Session. ## Architektur - Unauthenticated: `redirectToPortal({ next })` macht hartes Redirect zu `auth.mana.how/login?app=mana&redirect=`. AuthGate (`(app)/+layout.svelte`) und `require-auth` triggern das. - Nach Login: Portal setzt SSO-Cookie auf `.mana.how`. Browser landet auf `/auth/callback?next=`. - Callback: `session.tryRefresh()` holt frischen JWT via Cookie, `loadUserFromToken()` setzt User, `goto(next)` renderet (app)-Layout mit unlocked Vault (Root-Layout-$effect feuert auf User-ID-Wechsel). ## Files NEU: - `lib/auth/portal-redirect.ts` — Helper für Portal-URL-Bau + hard redirect. - `lib/auth/session.svelte.ts` — schlanke Session-Klasse: Token-Refresh via SSO-Cookie, ensureFresh, signOut. Storage: `mana.auth.accessToken`, `mana.auth.user`. - `lib/auth/settings-client.ts` — Passkey-CRUD, 2FA-Setup, Sessions, Audit-Events. Pflegt keinen State, ruft direkt mana-auth API. GELÖSCHT: - `routes/(auth)/login|register|forgot-password|reset-password|+layout` - `routes/auth/reset-password` (war Alias-Redirect) - Komplette `(auth)` route group. UMGESCHRIEBEN: - `lib/stores/auth.svelte.ts` — re-exportiert `session` als `authStore` (keine 47-Methoden-Factory aus `@mana/shared-auth-ui` mehr). - `routes/auth/callback/+page.svelte` — Token-Refresh + Deep-Link statt Legacy-Supabase-Stub. - `lib/components/settings/sections/SecuritySection.svelte` — alle `authStore.registerPasskey/enableTwoFactor/...` Calls auf neuen `settings-client` umgelenkt. UI-Komponenten (PasskeyManager, TwoFactorSetup, …) aus `@mana/shared-auth-ui` bleiben — sind reine Render-Components. ANGEPASST (Portal-Redirect statt `goto('/login')`): - `(app)/+layout.svelte`, `RouteTierGate`, `email-verified`, `verification-failed`, `feedback/+layout`, `quotes/lists`, `quotes/favorites`, `citycorners/favorites`, `feedback/DetailView`, `feedback/ListView`, `profile/ListView`, `guest-prompt`, `require-auth.svelte.ts`. ENV: - `.env.development`: `MANA_AUTH_WEB_URL=http://localhost:3002`. - `scripts/generate-env.mjs`: schreibt `PUBLIC_MANA_AUTH_URL` + `PUBLIC_AUTH_WEB_URL` ins `apps/mana/apps/web/.env`. ## Status - `pnpm run check`: 0 errors, 0 warnings, 7672 files. - `pnpm build` (8 GB heap): grün. - E2E lokal + Production-Deploy stehen aus — Plan siehe `mana/docs/playbooks/MANAGARTEN_AUTH_PORTAL_MIGRATION.md`. Co-Authored-By: Claude Sonnet 4.6 --- .env.development | 4 + .../apps/web/src/lib/auth/portal-redirect.ts | 71 +++++ .../web/src/lib/auth/require-auth.svelte.ts | 29 +- .../apps/web/src/lib/auth/session.svelte.ts | 271 ++++++++++++++++++ .../apps/web/src/lib/auth/settings-client.ts | 248 ++++++++++++++++ .../components/layout/RouteTierGate.svelte | 3 +- .../settings/sections/SecuritySection.svelte | 75 +++-- .../modules/feedback/views/DetailView.svelte | 3 +- .../modules/feedback/views/ListView.svelte | 3 +- .../src/lib/modules/profile/ListView.svelte | 5 +- .../apps/web/src/lib/stores/auth.svelte.ts | 29 +- .../web/src/lib/stores/guest-prompt.svelte.ts | 11 +- .../apps/web/src/routes/(app)/+layout.svelte | 11 +- .../(app)/citycorners/favorites/+page.svelte | 3 +- .../(app)/quotes/favorites/+page.svelte | 4 +- .../routes/(app)/quotes/lists/+page.svelte | 4 +- .../apps/web/src/routes/(auth)/+layout.svelte | 34 --- .../(auth)/forgot-password/+page.svelte | 28 -- .../web/src/routes/(auth)/login/+page.svelte | 62 ---- .../src/routes/(auth)/register/+page.svelte | 55 ---- .../routes/(auth)/reset-password/+page.svelte | 168 ----------- .../web/src/routes/auth/callback/+page.svelte | 99 +++---- .../routes/auth/reset-password/+page.svelte | 23 -- .../src/routes/email-verified/+page.svelte | 4 +- .../web/src/routes/feedback/+layout.svelte | 3 +- .../routes/verification-failed/+page.svelte | 6 +- scripts/generate-env.mjs | 6 + 27 files changed, 773 insertions(+), 489 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/auth/portal-redirect.ts create mode 100644 apps/mana/apps/web/src/lib/auth/session.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/auth/settings-client.ts delete mode 100644 apps/mana/apps/web/src/routes/(auth)/+layout.svelte delete mode 100644 apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(auth)/login/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(auth)/register/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte delete mode 100644 apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte diff --git a/.env.development b/.env.development index 4f9c72b9c..3dc68bcad 100644 --- a/.env.development +++ b/.env.development @@ -23,6 +23,10 @@ PUBLIC_GLITCHTIP_DSN= # Mana Core Auth Service MANA_AUTH_URL=http://localhost:3001 +# Auth-Portal-UI (Login/Register/Reset, getrennt vom Auth-API-Service). +# In Prod identisch mit MANA_AUTH_URL (nginx splittet /api/* zu mana-auth, +# Rest zu mana-auth-web), lokal aber eigener Port (mana-auth-web :3002). +MANA_AUTH_WEB_URL=http://localhost:3002 # Mana Credits Service MANA_CREDITS_URL=http://localhost:3061 # Mana Media Service (CAS, thumbnails, Photos gallery) diff --git a/apps/mana/apps/web/src/lib/auth/portal-redirect.ts b/apps/mana/apps/web/src/lib/auth/portal-redirect.ts new file mode 100644 index 000000000..394c5c975 --- /dev/null +++ b/apps/mana/apps/web/src/lib/auth/portal-redirect.ts @@ -0,0 +1,71 @@ +/** + * Portal-Redirect-Helper. + * + * Statt einer eigenen Login-Seite leitet managarten unauthenticated User + * an `auth.mana.how/login` weiter. Nach erfolgreichem Login landet der + * User auf `/auth/callback?next=…`, wo der Token via SSO-Cookie geholt + * und der Encryption-Vault entsperrt wird. + * + * In Dev: `PUBLIC_AUTH_WEB_URL=http://localhost:3002`. + * In Prod: `PUBLIC_AUTH_WEB_URL=https://auth.mana.how`. + * + * Diese Funktion macht einen harten `window.location.href` — keinen + * SvelteKit-`goto` — weil das Portal eine eigene Origin ist. + */ + +import { env as publicEnv } from '$env/dynamic/public'; + +const FALLBACK_PORTAL = 'https://auth.mana.how'; +const APP_ID = 'mana'; + +function portalUrl(): string { + return publicEnv.PUBLIC_AUTH_WEB_URL ?? FALLBACK_PORTAL; +} + +function appOrigin(): string { + if (typeof window === 'undefined') return 'https://mana.how'; + return window.location.origin; +} + +/** Baut die `/auth/callback?next=`-URL für die aktuelle App. */ +function callbackUrl(next: string | null | undefined): string { + const base = appOrigin(); + const nextParam = next && next.startsWith('/') ? `?next=${encodeURIComponent(next)}` : ''; + return `${base}/auth/callback${nextParam}`; +} + +export interface RedirectToPortalOptions { + /** + * Pfad innerhalb der App, zu dem der User nach Login zurückgeführt + * werden soll. Default: der aktuelle `window.location.pathname`. + * Pfade ohne führenden `/` werden ignoriert. + */ + next?: string; + /** Welche Portal-Page direkt öffnen — login (default) oder register. */ + target?: 'login' | 'register'; +} + +/** Hartes Redirect zum Auth-Portal. */ +export function redirectToPortal(options: RedirectToPortalOptions = {}): void { + if (typeof window === 'undefined') return; + const target = options.target ?? 'login'; + const next = options.next ?? window.location.pathname + window.location.search; + const url = new URL(`${portalUrl()}/${target}`); + url.searchParams.set('app', APP_ID); + url.searchParams.set('redirect', callbackUrl(next)); + window.location.href = url.toString(); +} + +/** + * Liefert die Portal-URL als String (z.B. für `` in + * server-rendered Content). Vorsicht: Bei Klick erfolgt ein voller + * Navigation-Refresh (App-Bundle wird neu geladen). + */ +export function portalHref(options: RedirectToPortalOptions = {}): string { + const target = options.target ?? 'login'; + const next = options.next ?? (typeof window !== 'undefined' ? window.location.pathname : '/'); + const url = new URL(`${portalUrl()}/${target}`); + url.searchParams.set('app', APP_ID); + url.searchParams.set('redirect', callbackUrl(next)); + return url.toString(); +} diff --git a/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts b/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts index c10ed2333..d37666ff5 100644 --- a/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts +++ b/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts @@ -56,9 +56,9 @@ * recovery UX. Mixing the two would muddy the message. */ -import { goto } from '$app/navigation'; import { page } from '$app/state'; import { authStore } from '$lib/stores/auth.svelte'; +import { redirectToPortal } from '$lib/auth/portal-redirect'; /** What requireAuth() needs to render the modal. */ export interface RequireAuthOptions { @@ -129,10 +129,12 @@ export const authGateState = new AuthGateState(); * - If guest → shows a modal and resolves to `true` if the user * logs in (and returns to the page), `false` if they cancel. * - * The modal navigates to `/login?next=` so the user - * lands back on the same view after logging in. The Promise then - * resolves on the *next* time `authStore.isAuthenticated` flips to - * `true` — the caller does NOT have to re-trigger their action. + * The modal navigates to `auth.mana.how/login?app=mana&redirect=` + * so the user lands back on `/auth/callback?next=` after + * the Portal-Login, where Token-Refresh + Vault-Unlock laufen, bevor + * `goto(next)` ins App-Innere weiterleitet. Die Promise resolved beim + * nächsten Mal, wenn `authStore.isAuthenticated` auf `true` flippt — der + * Aufrufer muss seine Action NICHT manuell re-triggern. */ export async function requireAuth(options: RequireAuthOptions): Promise { if (authStore.isAuthenticated) return true; @@ -140,19 +142,16 @@ export async function requireAuth(options: RequireAuthOptions): Promise } /** - * Called by AuthRequiredModal when the user clicks "Anmelden". Saves - * the current path so /login can redirect back, then navigates. + * Called by AuthRequiredModal when the user clicks "Anmelden". Hartes + * Redirect zum Auth-Portal — der aktuelle Pfad geht als `next`-Hint mit, + * damit der User nach Login + Vault-Unlock wieder hier landet. * - * The modal closes immediately on click. We deliberately do NOT wait - * for the post-login redirect to come back here — once the user - * navigates to /login, the original action's call site has lost its - * stack frame anyway. Instead, the user re-clicks the button after - * landing back on the page; the second click sees `isAuthenticated` - * is now true and proceeds without a modal. + * `authGateState.resolve(false)` läuft VOR dem `window.location.href`- + * Wechsel, weil der Modal-Caller den Promise sonst nie auflöst — der + * SvelteKit-Run-Loop wird durch das harte Redirect abgebrochen. */ export async function navigateToLogin(): Promise { const here = page.url?.pathname ?? '/'; - const next = here === '/login' ? '/' : here; - await goto(`/login?next=${encodeURIComponent(next)}`); authGateState.resolve(false); + redirectToPortal({ next: here }); } diff --git a/apps/mana/apps/web/src/lib/auth/session.svelte.ts b/apps/mana/apps/web/src/lib/auth/session.svelte.ts new file mode 100644 index 000000000..220885acf --- /dev/null +++ b/apps/mana/apps/web/src/lib/auth/session.svelte.ts @@ -0,0 +1,271 @@ +/** + * Mana Session — schlanker Auth-Client für die App-Seite. + * + * **Was diese Klasse macht:** Token-Lifecycle managen (Refresh via + * SSO-Cookie, lokale Persistierung im localStorage, proaktive + * Auffrischung vor Ablauf, Logout). Sie macht KEINE Login/Register- + * Flows — die laufen exklusiv über das zentrale Auth-Portal + * (`auth.mana.how`). + * + * **Wie der Flow läuft:** + * 1. Unauthenticated User landet auf `/` → `+page.svelte` redirected + * zum Portal mit `?app=mana&redirect=`. + * 2. User loggt sich auf dem Portal ein. Better-Auth setzt + * `mana_session` Cookie auf `.mana.how`. + * 3. Browser wird zurück zur App geleitet (`/auth/callback`). + * 4. `auth/callback/+page.svelte` ruft `session.tryRefresh()`. + * Der POST geht mit `credentials: 'include'` an + * `${MANA_AUTH_URL}/api/v1/auth/refresh` — der Browser sendet das + * SSO-Cookie automatisch mit. mana-auth liest das Cookie, + * validiert die Session, mintet einen frischen JWT und gibt ihn + * als JSON zurück. + * 5. Token landet im `localStorage` (`mana.auth.accessToken`), das + * User-Profil (aus den JWT-Claims) in `mana.auth.user`. + * 6. Vor jedem API-Call ruft der Caller `session.ensureFresh()` auf + * — wenn weniger als 60s Restlaufzeit, wird automatisch refreshed. + * + * **Was diese Klasse NICHT macht:** Settings-Methoden (Passkey-CRUD, + * 2FA-Setup, Sessions-Liste, Audit-Log). Die leben in + * `lib/auth/settings-client.ts` und nutzen den Token, den die + * Session vorhält. + */ + +import { env as publicEnv } from '$env/dynamic/public'; + +const TOKEN_KEY = 'mana.auth.accessToken'; +const USER_KEY = 'mana.auth.user'; + +const FALLBACK_AUTH_URL = 'https://auth.mana.how'; + +function authBaseUrl(): string { + return publicEnv.PUBLIC_MANA_AUTH_URL ?? FALLBACK_AUTH_URL; +} + +export interface SessionUser { + id: string; + email: string; + name: string | null; + role: string; + tier: string; + twoFactorEnabled?: boolean; +} + +interface JwtClaims { + sub: string; + email?: string; + name?: string; + role?: string; + tier?: string; + twoFactorEnabled?: boolean; + exp?: number; +} + +function decodeJwt(token: string): JwtClaims | null { + try { + const [, payload] = token.split('.'); + if (!payload) return null; + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const json = + typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8'); + return JSON.parse(json) as JwtClaims; + } catch { + return null; + } +} + +function isExpired(claims: JwtClaims | null): boolean { + if (!claims?.exp) return false; + return claims.exp * 1000 <= Date.now(); +} + +function userFromClaims(claims: JwtClaims): SessionUser { + return { + id: claims.sub, + email: claims.email ?? '', + name: claims.name ?? null, + role: claims.role ?? 'user', + tier: claims.tier ?? 'public', + twoFactorEnabled: claims.twoFactorEnabled, + }; +} + +class Session { + token = $state(null); + user = $state(null); + initialized = $state(false); + loading = $state(false); + + private refreshing: Promise | null = null; + + get isAuthenticated(): boolean { + return !!this.user && !!this.token; + } + + /** + * Einmaliger Boot-Pass: lädt persistierte Tokens, validiert sie, + * versucht stillschweigend eine SSO-Refresh wenn der lokale Token + * abgelaufen ist (Cookie könnte noch leben). + */ + async initialize(): Promise { + if (this.initialized) return; + this.initialized = true; + if (typeof window === 'undefined') return; + + const stored = window.localStorage.getItem(TOKEN_KEY); + const userJson = window.localStorage.getItem(USER_KEY); + + if (stored && userJson) { + const claims = decodeJwt(stored); + if (claims && !isExpired(claims)) { + this.token = stored; + try { + this.user = JSON.parse(userJson) as SessionUser; + } catch { + this.user = userFromClaims(claims); + } + return; + } + if (claims) { + // JWT abgelaufen — versuche stillschweigend zu refreshen + // (SSO-Cookie kann noch leben). + try { + this.user = JSON.parse(userJson) as SessionUser; + } catch { + this.user = userFromClaims(claims); + } + const ok = await this.tryRefresh(); + if (!ok) this.clearLocal(); + return; + } + } + + // Kein Token lokal — letzter Versuch: vielleicht haben wir + // trotzdem ein SSO-Cookie (z.B. anderer Tab hat eingeloggt). + await this.tryRefresh(); + } + + /** + * Holt einen frischen JWT vom Server. Benötigt das SSO-Cookie + * (wird vom Browser automatisch mitgesendet). + * + * Returns `true` wenn der Refresh erfolgreich war, sonst `false`. + * Bei `false` ist die Session geleert worden. + */ + async tryRefresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = (async () => { + try { + const res = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return false; + const data = (await res.json()) as { accessToken?: string }; + if (!data.accessToken) return false; + this.token = data.accessToken; + const claims = decodeJwt(data.accessToken); + if (claims) { + this.user = userFromClaims(claims); + } + this.persist(); + return true; + } catch { + return false; + } finally { + this.refreshing = null; + } + })(); + return this.refreshing; + } + + /** + * Lädt das User-Profil aus dem aktuell gehaltenen Token. Aufrufen + * direkt nach `tryRefresh()` im Callback-Handler, damit `user` synchron + * gesetzt ist bevor das Layout rendert. + */ + loadUserFromToken(): void { + if (!this.token) return; + const claims = decodeJwt(this.token); + if (!claims) return; + this.user = userFromClaims(claims); + this.persist(); + } + + /** Liefert den aktuellen Token. Refresht silently bei nahem Ablauf. */ + async getValidToken(): Promise { + await this.ensureFresh(); + return this.token; + } + + /** Synchroner Getter — kein Refresh. Nutzt das, was lokal liegt. */ + getAccessToken(): string | null { + return this.token; + } + + /** Wenn der Token <60s Restlaufzeit hat: refreshen. */ + async ensureFresh(): Promise { + if (!this.token) return; + const claims = decodeJwt(this.token); + if (!claims?.exp) return; + const remainingMs = claims.exp * 1000 - Date.now(); + if (remainingMs < 60_000) { + await this.tryRefresh(); + } + } + + /** + * Logout. Ruft den Portal-Endpoint zum Cookie-Invalidieren auf + * (best-effort), löscht lokale Tokens. + * + * Der Caller ist verantwortlich, danach den Redirect zum Portal + * zu machen (`redirectToPortal()`) — die Session selbst macht keine + * Navigation. + */ + async signOut(): Promise { + const had = !!this.token; + this.token = null; + this.user = null; + this.clearLocal(); + if (had && typeof window !== 'undefined') { + try { + await fetch(`${authBaseUrl()}/api/v1/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + } catch { + // Best-effort — auch ohne Server-Logout ist der lokale + // Token weg und der Caller leitet eh zum Portal weiter. + } + } + } + + /** Komplett-Reset ohne Server-Call (z.B. bei API-401). */ + clear(): void { + this.token = null; + this.user = null; + this.clearLocal(); + } + + private persist(): void { + if (typeof window === 'undefined') return; + if (this.token) window.localStorage.setItem(TOKEN_KEY, this.token); + else window.localStorage.removeItem(TOKEN_KEY); + if (this.user) window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + else window.localStorage.removeItem(USER_KEY); + } + + private clearLocal(): void { + if (typeof window === 'undefined') return; + window.localStorage.removeItem(TOKEN_KEY); + window.localStorage.removeItem(USER_KEY); + } +} + +/** Singleton — die ganze App nutzt diese Instanz. */ +export const session = new Session(); + +/** Internal helper für andere Auth-Module (settings-client, vault-unlock). */ +export function _authBaseUrl(): string { + return authBaseUrl(); +} diff --git a/apps/mana/apps/web/src/lib/auth/settings-client.ts b/apps/mana/apps/web/src/lib/auth/settings-client.ts new file mode 100644 index 000000000..39eaf6772 --- /dev/null +++ b/apps/mana/apps/web/src/lib/auth/settings-client.ts @@ -0,0 +1,248 @@ +/** + * Settings-Client — Account-Settings-Operationen (Passkey-CRUD, + * 2FA-Setup, Sessions-Liste, Audit-Log). + * + * Diese Methoden brauchen einen authentisierten User. Sie ziehen + * den Token aus dem `session`-Singleton (`session.svelte.ts`). + * + * Alle Calls gehen gegen `mana-auth` (BASE_URL aus + * `PUBLIC_MANA_AUTH_URL`). Cookies werden mitgesendet (Better-Auth + * verwaltet zusätzlich zur JWT-Auth den Session-State auch im + * Cookie für CSRF / Re-Auth-Flows). + */ + +import { _authBaseUrl, session } from './session.svelte'; + +async function authHeaders(): Promise { + const token = await session.getValidToken(); + if (!token) throw new Error('Not authenticated'); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} + +async function expectOk(res: Response, op: string): Promise { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + typeof body === 'object' && body && 'message' in body + ? String((body as { message: unknown }).message) + : `${op} failed (HTTP ${res.status})` + ); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +// ─── Passkeys ──────────────────────────────────────────────── + +/** Shape, der von der UI (`PasskeyManager` aus `@mana/shared-auth-ui`) erwartet wird. */ +export interface PasskeyEntry { + id: string; + credentialId: string; + deviceType: string; + backedUp: boolean; + friendlyName: string | null; + lastUsedAt: string | null; + createdAt: string; +} + +export interface PasskeyCapability { + browser: boolean; + conditionalUI: boolean; + server: boolean; + available: boolean; + rpId: string | null; +} + +export const passkeys = { + async list(): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys`, { + credentials: 'include', + headers: await authHeaders(), + }); + const data = (await expectOk(res, 'passkeys.list')) as { passkeys?: PasskeyEntry[] }; + return data?.passkeys ?? []; + }, + + async capability(): Promise { + const base = _authBaseUrl(); + const res = await fetch(`${base}/api/v1/auth/passkeys/capability`); + if (!res.ok) { + return { browser: false, conditionalUI: false, server: false, available: false, rpId: null }; + } + const data = (await res.json()) as { enabled?: boolean; rpId?: string | null }; + const browser = typeof window !== 'undefined' && 'PublicKeyCredential' in window; + let conditionalUI = false; + if (browser) { + try { + const PKC = (window as unknown as { PublicKeyCredential: typeof PublicKeyCredential }) + .PublicKeyCredential; + if (typeof PKC.isConditionalMediationAvailable === 'function') { + conditionalUI = await PKC.isConditionalMediationAvailable(); + } + } catch { + /* ignore */ + } + } + const server = !!data.enabled; + return { + browser, + conditionalUI, + server, + available: browser && server, + rpId: data.rpId ?? null, + }; + }, + + async register(friendlyName?: string): Promise { + const { startRegistration } = await import('@simplewebauthn/browser'); + const headers = await authHeaders(); + + const optionsRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/options`, { + method: 'POST', + credentials: 'include', + headers, + }); + const webauthnOptions = (await expectOk(optionsRes, 'passkeys.register.options')) as Parameters< + typeof startRegistration + >[0]['optionsJSON']; + + const credential = await startRegistration({ optionsJSON: webauthnOptions }); + + const verifyRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/verify`, { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify({ response: credential, name: friendlyName }), + }); + await expectOk(verifyRes, 'passkeys.register.verify'); + }, + + async delete(passkeyId: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, { + method: 'DELETE', + credentials: 'include', + headers: await authHeaders(), + }); + await expectOk(res, 'passkeys.delete'); + }, + + async rename(passkeyId: string, friendlyName: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, { + method: 'PATCH', + credentials: 'include', + headers: await authHeaders(), + body: JSON.stringify({ name: friendlyName }), + }); + await expectOk(res, 'passkeys.rename'); + }, +}; + +// ─── 2FA (TOTP + Backup Codes) ─────────────────────────────── + +export interface TwoFactorEnableResponse { + secret: string; + uri: string; + backupCodes: string[]; +} + +export interface BackupCodesResponse { + backupCodes: string[]; +} + +export const twoFactor = { + async enable(password: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/enable`, { + method: 'POST', + credentials: 'include', + headers: await authHeaders(), + body: JSON.stringify({ password }), + }); + const data = (await expectOk(res, 'twoFactor.enable')) as TwoFactorEnableResponse; + // Refresh Token so der JWT den neuen `twoFactorEnabled`-Claim trägt. + await session.tryRefresh(); + return data; + }, + + async disable(password: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/disable`, { + method: 'POST', + credentials: 'include', + headers: await authHeaders(), + body: JSON.stringify({ password }), + }); + await expectOk(res, 'twoFactor.disable'); + await session.tryRefresh(); + }, + + async generateBackupCodes(password: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/generate-backup-codes`, { + method: 'POST', + credentials: 'include', + headers: await authHeaders(), + body: JSON.stringify({ password }), + }); + return (await expectOk(res, 'twoFactor.generateBackupCodes')) as BackupCodesResponse; + }, +}; + +// ─── Sessions ──────────────────────────────────────────────── + +/** Shape, der von der UI (`SessionManager` aus `@mana/shared-auth-ui`) erwartet wird. */ +export interface SessionEntry { + id: string; + ipAddress: string | null; + userAgent: string | null; + deviceId: string | null; + deviceName: string | null; + lastActivityAt: string | null; + createdAt: string; + expiresAt: string; +} + +export const sessions = { + async list(): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions`, { + credentials: 'include', + headers: await authHeaders(), + }); + const data = (await expectOk(res, 'sessions.list')) as { sessions?: SessionEntry[] }; + return data?.sessions ?? []; + }, + + async revoke(sessionId: string): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions/${sessionId}`, { + method: 'DELETE', + credentials: 'include', + headers: await authHeaders(), + }); + await expectOk(res, 'sessions.revoke'); + }, +}; + +// ─── Audit / Security Events ───────────────────────────────── + +/** Shape, der von der UI (`AuditLog` aus `@mana/shared-auth-ui`) erwartet wird. */ +export interface SecurityEvent { + id: string; + eventType: string; + ipAddress: string | null; + userAgent: string | null; + metadata: unknown; + createdAt: string; +} + +export const audit = { + async getSecurityEvents(limit = 50): Promise { + const res = await fetch(`${_authBaseUrl()}/api/v1/auth/security-events?limit=${limit}`, { + credentials: 'include', + headers: await authHeaders(), + }); + const data = (await expectOk(res, 'audit.getSecurityEvents')) as { + events?: SecurityEvent[]; + }; + return data?.events ?? []; + }, +}; diff --git a/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte b/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte index de027e8db..8a76ea7e9 100644 --- a/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte @@ -7,6 +7,7 @@ import { goto } from '$app/navigation'; import { locale } from 'svelte-i18n'; import { authStore } from '$lib/stores/auth.svelte'; + import { redirectToPortal } from '$lib/auth/portal-redirect'; interface Props { appName: string; @@ -71,7 +72,7 @@ class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90" style:border-color="hsl(var(--color-border, 0 0% 90%))" style:color="hsl(var(--color-foreground, 0 0% 9%))" - onclick={() => goto('/login')} + onclick={() => redirectToPortal()} > {isDE ? 'Anmelden' : 'Sign in'} diff --git a/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte b/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte index 3a9229805..e92ba7eff 100644 --- a/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte +++ b/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte @@ -4,25 +4,66 @@ import { ShieldCheck } from '@mana/shared-icons'; import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui'; import { authStore } from '$lib/stores/auth.svelte'; + import { + passkeys as passkeysClient, + sessions as sessionsClient, + twoFactor as twoFactorClient, + audit as auditClient, + type PasskeyEntry, + type SessionEntry, + type SecurityEvent, + } from '$lib/auth/settings-client'; import SettingsPanel from '../SettingsPanel.svelte'; import SettingsSectionHeader from '../SettingsSectionHeader.svelte'; import VaultSection from './VaultSection.svelte'; - let passkeys = $state([]); - let sessions = $state([]); + let passkeys = $state([]); + let passkeyAvailable = $state(false); + let sessions = $state([]); let sessionsLoading = $state(false); - let securityEvents = $state([]); + let securityEvents = $state([]); let securityEventsLoading = $state(false); + // Adapter: die UI-Komponenten erwarten `{ success, error? }`-Returns, + // unsere Settings-Client-Methoden werfen. Hier einmal zentral übersetzen. + function asResult(p: Promise): Promise<{ success: boolean; error?: string }> { + return p + .then(() => ({ success: true as const })) + .catch((e: unknown) => ({ + success: false as const, + error: e instanceof Error ? e.message : String(e), + })); + } + + async function handleEnable(password: string) { + try { + const r = await twoFactorClient.enable(password); + return { success: true as const, totpURI: r.uri, backupCodes: r.backupCodes }; + } catch (e) { + return { success: false as const, error: e instanceof Error ? e.message : String(e) }; + } + } + + async function handleGenerateBackupCodes(password: string) { + try { + const r = await twoFactorClient.generateBackupCodes(password); + return { success: true as const, backupCodes: r.backupCodes }; + } catch (e) { + return { success: false as const, error: e instanceof Error ? e.message : String(e) }; + } + } + onMount(async () => { if (!authStore.isAuthenticated) return; try { - passkeys = await authStore.listPasskeys(); + const cap = await passkeysClient.capability(); + passkeyAvailable = cap.available; + passkeys = await passkeysClient.list(); sessionsLoading = true; - sessions = await authStore.listSessions(); + sessions = await sessionsClient.list(); sessionsLoading = false; securityEventsLoading = true; - securityEvents = await authStore.getSecurityEvents(); + securityEvents = await auditClient.getSecurityEvents(); securityEventsLoading = false; } catch (e) { console.error('SecuritySection load failed:', e); @@ -44,12 +85,12 @@ authStore.registerPasskey(name)} - onDelete={(id) => authStore.deletePasskey(id)} - onRename={(id, name) => authStore.renamePasskey(id, name)} + {passkeyAvailable} + onRegister={(name) => asResult(passkeysClient.register(name))} + onDelete={(id) => asResult(passkeysClient.delete(id))} + onRename={(id, name) => asResult(passkeysClient.rename(id, name))} onRefresh={async () => { - passkeys = await authStore.listPasskeys(); + passkeys = await passkeysClient.list(); }} primaryColor="hsl(var(--color-primary))" /> @@ -59,10 +100,10 @@ authStore.revokeSession(id)} + onRevoke={(id) => asResult(sessionsClient.revoke(id))} onRefresh={async () => { sessionsLoading = true; - sessions = await authStore.listSessions(); + sessions = await sessionsClient.list(); sessionsLoading = false; }} primaryColor="hsl(var(--color-primary))" @@ -72,9 +113,9 @@ authStore.enableTwoFactor(password)} - onDisable={(password) => authStore.disableTwoFactor(password)} - onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)} + onEnable={handleEnable} + onDisable={(password) => asResult(twoFactorClient.disable(password))} + onGenerateBackupCodes={handleGenerateBackupCodes} primaryColor="hsl(var(--color-primary))" /> @@ -89,7 +130,7 @@ loading={securityEventsLoading} onRefresh={async () => { securityEventsLoading = true; - securityEvents = await authStore.getSecurityEvents(); + securityEvents = await auditClient.getSecurityEvents(); securityEventsLoading = false; }} primaryColor="hsl(var(--color-primary))" diff --git a/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte index 70b5c6d5f..aaa00518e 100644 --- a/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte @@ -9,6 +9,7 @@ import ItemCard from '../components/ItemCard.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { feedbackService } from '$lib/api/feedback'; + import { portalHref } from '$lib/auth/portal-redirect'; interface Props { id: string; @@ -118,7 +119,7 @@ {:else} {/if} diff --git a/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte index 8da737e80..79e821a40 100644 --- a/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte @@ -10,6 +10,7 @@ import { useCommunityFeed, toggleReactionOnItem } from '../queries.svelte'; import ItemCard from '../components/ItemCard.svelte'; import { authStore } from '$lib/stores/auth.svelte'; + import { portalHref } from '$lib/auth/portal-redirect'; interface Props { /** Optional initial moduleContext filter — passed by the @@ -82,7 +83,7 @@ Noch keine Stimmen — sei der erste, der was reinwirft. {#if !authStore.user}
- Login, um mitzumachen. + Login, um mitzumachen. {/if} {:else} diff --git a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte index 05270e579..bc91db851 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte @@ -7,6 +7,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { toast } from '$lib/stores/toast.svelte'; import { goto } from '$app/navigation'; + import { redirectToPortal } from '$lib/auth/portal-redirect'; import { EditProfileModal, ChangePasswordModal, @@ -69,7 +70,7 @@ async function handleAccountDeleted() { toast.info($_('profile.hub.toast_account_deleting')); await authStore.signOut(); - goto('/login'); + redirectToPortal({ next: '/' }); } @@ -232,7 +233,7 @@ class="account-btn" onclick={async () => { await authStore.signOut(); - goto('/login'); + redirectToPortal({ next: '/' }); }} > {$_('profile.logout')} diff --git a/apps/mana/apps/web/src/lib/stores/auth.svelte.ts b/apps/mana/apps/web/src/lib/stores/auth.svelte.ts index 5b3423b04..81664a5ad 100644 --- a/apps/mana/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/auth.svelte.ts @@ -1,7 +1,28 @@ /** - * Auth Store — uses centralized Mana auth factory. + * Auth Store — re-export der neuen Session-Klasse. + * + * Bis 2026-05-12 war das ein Wrapper über `createManaAuthStore()` aus + * `@mana/shared-auth-ui`, der 47 Methoden mitbrachte (Login, Register, + * Reset, Passkey-CRUD, 2FA-Setup, Sessions, Audit). Mit dem Wechsel + * auf den zentralen Auth-Portal sind die Login-/Register-/Reset-Flows + * komplett rausgeflogen — sie leben jetzt auf `auth.mana.how`. + * + * Was bleibt: + * - `authStore.user`, `authStore.isAuthenticated`, `authStore.initialized`, + * `authStore.loading` — der reaktive Session-State. + * - `authStore.initialize()` — Boot-Pass. + * - `authStore.signOut()` — Logout (kein Redirect; den macht der Caller). + * - `authStore.getValidToken()`, `authStore.getAccessToken()` — Token-Access. + * + * Was wegfällt (Settings → `$lib/auth/settings-client.ts`): + * - Passkey-CRUD → `import { passkeys } from '$lib/auth/settings-client'` + * - 2FA-Setup → `import { twoFactor } from '$lib/auth/settings-client'` + * - Sessions / Audit → `import { sessions, audit } from '$lib/auth/settings-client'` + * + * Was ganz wegfällt (passiert jetzt im Portal): + * - `signIn`, `signUp`, `resetPassword`, `resetPasswordWithToken`, + * `resendVerificationEmail`, `verifyTwoFactor`, `verifyBackupCode`, + * `sendMagicLink`, `signInWithPasskey`. */ -import { createManaAuthStore } from '@mana/shared-auth-ui'; - -export const authStore = createManaAuthStore(); +export { session as authStore } from '$lib/auth/session.svelte'; diff --git a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts index 2fd43fcec..6e0b709ae 100644 --- a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts @@ -25,12 +25,15 @@ */ import type { BottomNotification } from '@mana/shared-ui'; +import { portalHref } from '$lib/auth/portal-redirect'; let prompts = $state([]); -/** Default action target — the login page already exposes both login - * and register flows so we point at one URL and let the user pick. */ -const DEFAULT_LOGIN_HREF = '/login'; +/** Default action target — the central auth portal (auth.mana.how) handles + * both login and register flows in one UI. */ +function defaultLoginHref(): string { + return portalHref(); +} /** Navigates the browser. Kept as a small wrapper so unit tests can * swap it out without pulling SvelteKit's `goto`. */ @@ -79,7 +82,7 @@ export const guestPrompt = { action: { label: actionLabel, onClick: () => { - navigate(DEFAULT_LOGIN_HREF); + navigate(defaultLoginHref()); guestPrompt.dismiss(id); }, }, diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 800fcb1d3..3fff16241 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -7,6 +7,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; + import { redirectToPortal, portalHref } from '$lib/auth/portal-redirect'; import type { Component, Snippet } from 'svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte'; import FeedbackQuickModal from '$lib/components/feedback/FeedbackQuickModal.svelte'; @@ -497,7 +498,7 @@ guestMode?.destroy(); setErrorTrackingUser(null); await authStore.signOut(); - goto('/login'); + redirectToPortal({ next: '/' }); } // ── Guest Mode ────────────────────────────────────────── @@ -739,7 +740,7 @@ markAsGuest(); guestMode = createGuestMode('mana', { nudgeDelayMinutes: 3, - onRegister: () => goto('/register'), + onRegister: () => redirectToPortal({ target: 'register' }), }); } } @@ -1062,7 +1063,7 @@ {languageItems} {currentLanguageLabel} showLogout={authStore.isAuthenticated} - loginHref="/login" + loginHref={portalHref()} primaryColor="hsl(var(--color-primary))" showAppSwitcher={false} showAiTierSelector={true} @@ -1152,8 +1153,8 @@ appId="mana" visible={guestMode.showWelcome} onClose={() => guestMode?.dismissWelcome()} - onLogin={() => goto('/login')} - onRegister={() => goto('/register')} + onLogin={() => redirectToPortal()} + onRegister={() => redirectToPortal({ target: 'register' })} locale={($locale || 'de') === 'de' ? 'de' : 'en'} /> {/if} diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte index 95259e557..a63994607 100644 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte @@ -2,6 +2,7 @@ import { Heart } from '@mana/shared-icons'; import { _ } from 'svelte-i18n'; import { authStore } from '$lib/stores/auth.svelte'; + import { portalHref } from '$lib/auth/portal-redirect'; import { RoutePage } from '$lib/components/shell'; import { favoritesStore, @@ -38,7 +39,7 @@

{$_('favorites.loginRequired')}

{$_('settings.login')} diff --git a/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte b/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte index 38b1021ce..fb8a7a2da 100644 --- a/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte @@ -1,8 +1,8 @@ - - - - -{@render children()} diff --git a/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte deleted file mode 100644 index 3005fdda4..000000000 --- a/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - {#snippet appSlider()} - - {/snippet} - diff --git a/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte deleted file mode 100644 index 494d69c6f..000000000 --- a/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - - authStore.signInWithPasskey()} - onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} - onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} - onSendMagicLink={(email) => authStore.sendMagicLink(email)} - {goto} - successRedirect="/" - registerPath="/register" - forgotPasswordPath="/forgot-password" - lightBackground="#f3f4f6" - darkBackground="#121212" - {translations} - {verified} - {initialEmail} - {isDark} - version={APP_VERSION} - buildTime={BUILD_TIME} -> - {#snippet headerControls()} - - {/snippet} - {#snippet appSlider()} - - {/snippet} - diff --git a/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte deleted file mode 100644 index bd28dde7a..000000000 --- a/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - - - {translations.title} | Mana - - - - {#snippet appSlider()} - - {/snippet} - diff --git a/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte deleted file mode 100644 index daf10c97c..000000000 --- a/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - -
-
-

Reset Password

-

- {#if success} - Password reset successfully - {:else if hasToken} - Enter your new password - {:else} - Invalid or missing reset token - {/if} -

-
- - {#if success} - -
- - {:else if hasToken} - -
- {#if error} -
- {error} -
- {/if} - -
-
- - -

- Must be at least 12 characters -

-
- -
- - -
- -
- -
-
-
-
- {:else} - -
-
⚠️
-

- This password reset link is invalid or has expired. -

- - Request a new reset link - -
-
- {/if} -
diff --git a/apps/mana/apps/web/src/routes/auth/callback/+page.svelte b/apps/mana/apps/web/src/routes/auth/callback/+page.svelte index 325345916..d9aaa6c06 100644 --- a/apps/mana/apps/web/src/routes/auth/callback/+page.svelte +++ b/apps/mana/apps/web/src/routes/auth/callback/+page.svelte @@ -1,88 +1,71 @@ - Authenticating... - Mana + Anmeldung wird abgeschlossen – Mana
- {#if processing} -
-

Authenticating...

-

Please wait while we complete your sign-in.

- {:else if error} + {#if error}
⚠️
-

Authentication Error

+

+ Authentifizierungsfehler +

{error}

-

Redirecting you back to login...

+

+ Du wirst zurück zur Anmeldung geleitet… +

+ {:else} +
+

Anmeldung wird abgeschlossen…

+

Einen Moment bitte.

{/if}
diff --git a/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte b/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte deleted file mode 100644 index ff15f6e4c..000000000 --- a/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
-
-
-

Redirecting...

-
-
diff --git a/apps/mana/apps/web/src/routes/email-verified/+page.svelte b/apps/mana/apps/web/src/routes/email-verified/+page.svelte index c99361c89..c937da4d4 100644 --- a/apps/mana/apps/web/src/routes/email-verified/+page.svelte +++ b/apps/mana/apps/web/src/routes/email-verified/+page.svelte @@ -1,6 +1,6 @@ @@ -26,7 +26,7 @@