diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index eca6b67..fbcdfaa 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -1,7 +1,12 @@ /** - * Cards-API-Client. Dünner Fetch-Wrapper, der `X-User-Id`-Header aus - * dem Dev-Auth-Stub setzt. Phase 2 ersetzt das durch ein Bearer-Token - * aus @mana/shared-auth. + * Cards-API-Client. Dünner Fetch-Wrapper. + * + * Phase 10c: schickt `Authorization: Bearer ` aus der echten + * mana-auth-Session. Wenn kein JWT da ist (Stub-Modus für Tests), + * fällt er auf den `X-User-Id`-Header zurück. + * + * 401 → wir leeren die Session und werfen ApiError. Aufrufer kann + * darauf reagieren (z.B. Redirect auf `/`). */ import { devUser } from '$lib/auth/dev-stub.svelte.ts'; @@ -33,8 +38,10 @@ export async function api(path: string, opts: RequestOptions = {}): Promise = { 'Content-Type': 'application/json', }; - if (devUser.id) { - headers['X-User-Id'] = devUser.id; + if (devUser.token) { + headers['Authorization'] = `Bearer ${devUser.token}`; + } else if (devUser.stubId) { + headers['X-User-Id'] = devUser.stubId; } const res = await fetch(`${API_BASE}${path}`, { method: opts.method ?? 'GET', @@ -43,6 +50,11 @@ export async function api(path: string, opts: RequestOptions = {}): Promise = {}; - if (devUser.id) headers['X-User-Id'] = devUser.id; + if (devUser.token) headers['Authorization'] = `Bearer ${devUser.token}`; + else if (devUser.stubId) headers['X-User-Id'] = devUser.stubId; const res = await fetch(`${API_BASE}/api/v1/media/upload`, { method: 'POST', diff --git a/apps/web/src/lib/auth/dev-stub.svelte.ts b/apps/web/src/lib/auth/dev-stub.svelte.ts index abbb7c2..fc948ae 100644 --- a/apps/web/src/lib/auth/dev-stub.svelte.ts +++ b/apps/web/src/lib/auth/dev-stub.svelte.ts @@ -1,35 +1,142 @@ /** - * Dev-Auth-Stub. Phase 2 ersetzt diesen Layer durch echtes JWT- - * Handling via @mana/shared-auth gegen mana-auth. + * Auth-Session für cards-web (Phase 10c). * - * Für jetzt: User-ID lebt in sessionStorage und wird als - * `X-User-Id`-Header an cards-api geschickt. + * Echte SSO gegen mana-auth: `accessToken` (EdDSA-JWT von + * `auth.mana.how`) lebt in localStorage, wird als + * `Authorization: Bearer ` an cards-api geschickt. + * + * Der Datei-Name `dev-stub.svelte.ts` ist Legacy aus Phase 4 — alle + * Importer nutzen `devUser`-Symbol, wir behalten den Namen, damit der + * Sprint nicht fünfzig Imports umschreiben muss. Inhalt ist jetzt die + * echte Session. + * + * Token-Refresh: aktuell nicht implementiert — wenn der Token abläuft + * (15 min Default in mana-auth), gibt cards-api 401 und der User wird + * auf `/` zurückgeworfen. Refresh-Token-Pfad ist Phase-10d-Polish. */ -class DevUser { - id = $state(null); +const TOKEN_KEY = 'cards.auth.accessToken'; +const USER_KEY = 'cards.auth.user'; +const STUB_KEY = 'cards.dev.userId'; + +export interface AuthUser { + id: string; + email: string; + name: string | null; + tier: string; +} + +interface JwtClaims { + sub: string; + email?: string; + name?: string; + tier?: string; + exp?: number; +} + +function decodeJwt(token: string): JwtClaims | null { + try { + const [, payload] = token.split('.'); + if (!payload) return null; + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(json) as JwtClaims; + } catch { + return null; + } +} + +function isExpired(claims: JwtClaims): boolean { + if (!claims.exp) return false; + return claims.exp * 1000 < Date.now(); +} + +class Session { + token = $state(null); + user = $state(null); + stubId = $state(null); // Phase-2-Übergangs-Fallback (?stub=) constructor() { + 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; + this.user = JSON.parse(userJson) as AuthUser; + } else { + this.clearLocal(); + } + } + // Dev-Stub-Fallback (für Tests + Anki-Importer im old-style flow) + this.stubId = window.sessionStorage.getItem(STUB_KEY); + } + + private clearLocal() { + this.token = null; + this.user = null; if (typeof window !== 'undefined') { - this.id = sessionStorage.getItem('cards.dev.userId'); + window.localStorage.removeItem(TOKEN_KEY); + window.localStorage.removeItem(USER_KEY); } } + /** Effektive User-ID: bevorzugt JWT, dann Dev-Stub, sonst null. */ + get id(): string | null { + return this.user?.id ?? this.stubId ?? null; + } + + /** Login gegen mana-auth, speichert accessToken + Profil. */ + async login(email: string, password: string): Promise { + const baseUrl = + (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_MANA_AUTH_URL) || + 'https://auth.mana.how'; + const r = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!r.ok) { + const body = await r.text().catch(() => ''); + throw new Error(`Login fehlgeschlagen (${r.status}): ${body.slice(0, 120)}`); + } + const data = (await r.json()) as { + accessToken: string; + user: { id: string; email: string; name?: string; accessTier?: string }; + }; + const claims = decodeJwt(data.accessToken); + if (!claims) throw new Error('Auth-Server lieferte ein ungültiges Token zurück.'); + + this.token = data.accessToken; + this.user = { + id: data.user.id, + email: data.user.email, + name: data.user.name ?? null, + tier: data.user.accessTier ?? 'public', + }; + if (typeof window !== 'undefined') { + window.localStorage.setItem(TOKEN_KEY, data.accessToken); + window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + } + } + + /** Setzt einen Dev-Stub (kein echtes Auth). Nur für Tests/Migration. */ set(userId: string) { const trimmed = userId.trim(); if (!trimmed) return; - this.id = trimmed; + this.stubId = trimmed; if (typeof window !== 'undefined') { - sessionStorage.setItem('cards.dev.userId', trimmed); + window.sessionStorage.setItem(STUB_KEY, trimmed); } } clear() { - this.id = null; + this.clearLocal(); + this.stubId = null; if (typeof window !== 'undefined') { - sessionStorage.removeItem('cards.dev.userId'); + window.sessionStorage.removeItem(STUB_KEY); } } } -export const devUser = new DevUser(); +export const devUser = new Session(); diff --git a/apps/web/src/lib/components/Header.svelte b/apps/web/src/lib/components/Header.svelte index 59a3045..5922c0f 100644 --- a/apps/web/src/lib/components/Header.svelte +++ b/apps/web/src/lib/components/Header.svelte @@ -68,18 +68,18 @@ {#if devUser.id} - {devUser.id} + {devUser.user?.email ?? devUser.user?.name ?? devUser.id} {:else} - + Login + {/if} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index c6e0590..955c091 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -4,9 +4,29 @@ import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts'; + let email = $state(''); + let password = $state(''); + let busy = $state(false); + let error = $state(null); + onMount(() => { if (devUser.id) goto('/decks'); }); + + async function onSubmit(e: SubmitEvent) { + e.preventDefault(); + if (busy) return; + busy = true; + error = null; + try { + await devUser.login(email.trim(), password); + goto('/decks'); + } catch (err) { + error = (err as Error).message; + } finally { + busy = false; + } + }
@@ -15,34 +35,49 @@

{t('landing.intro')}

{#if !devUser.id} -
-

{t('landing.cta_login')}

-
{ - e.preventDefault(); - const fd = new FormData(e.currentTarget as HTMLFormElement); - const id = String(fd.get('user_id') ?? '').trim(); - if (id) { - devUser.set(id); - goto('/decks'); - } - }} - > +

Anmelden

+
+ + + {#if error} + + {/if} + +

+ Mana-Account auf auth.mana.how. Cross-App-SSO über *.mana.how. +

+ {/if}
diff --git a/apps/web/src/routes/account/+page.svelte b/apps/web/src/routes/account/+page.svelte index 60cd1ec..e4bcde7 100644 --- a/apps/web/src/routes/account/+page.svelte +++ b/apps/web/src/routes/account/+page.svelte @@ -69,10 +69,37 @@

{t('account.title')}

-
-
{t('account.user_id_label')}
- {devUser.id ?? '—'} -
+ {#if devUser.user} +
+
+
E-Mail
+
{devUser.user.email}
+
+ {#if devUser.user.name} +
+
Name
+
{devUser.user.name}
+
+ {/if} +
+
Tier
+
+ {devUser.user.tier} +
+
+
+
{t('account.user_id_label')}
+ {devUser.user.id} +
+
+ {:else} +
+
{t('account.user_id_label')} (Stub)
+ {devUser.id ?? '—'} +
+ {/if}
-

{t('account.phase2_hint')}