Phase 10c: Cards-Web SSO-Login gegen mana-auth
Some checks are pending
CI / validate (push) Waiting to run

Echte Anmeldung gegen auth.mana.how/api/v1/auth/login statt
Dev-Stub-User-ID. accessToken (EdDSA-JWT, 15 min TTL) + Profil
(email, name, tier) leben in localStorage; jeder API-Call schickt
`Authorization: Bearer <jwt>`. Bei 401 wird die Session lokal
geleert — User landet beim nächsten Render auf der Login-Page.

`devUser.id` bleibt eine Vereinfachte UI-Sentinel (gibt id wenn
JWT ODER Dev-Stub aktiv) — alle existierenden Importer
funktionieren unverändert. Dev-Stub-Pfad bleibt als Fallback für
Tests + Anki-Importer-Migration. Filename `dev-stub.svelte.ts`
behalten, Inhalt komplett umgebaut (Sprint 10d wäre der Rename).

Account-Page zeigt Email + Name + Tier statt nur UUID. Header
zeigt Email statt UUID. Login-Form auf Landing-Page mit Email +
Passwort, error-Anzeige, autocomplete-Hints für Browser-Manager.

uploadMedia (multipart) angepasst: Bearer first, X-User-Id-Stub
als Fallback.

svelte-check 384 files 0 errors, 7 Web-Tests grün, prod-Build
sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 21:08:06 +02:00
parent a960d09e5b
commit 7119756ce6
6 changed files with 236 additions and 55 deletions

View file

@ -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 <jwt>` 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<T>(path: string, opts: RequestOptions = {}): Promise<T
const headers: Record<string, string> = {
'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<T>(path: string, opts: RequestOptions = {}): Promise<T
signal: opts.signal,
});
if (!res.ok) {
if (res.status === 401 && devUser.token) {
// Token vermutlich abgelaufen — Session lokal aufräumen, sodass
// die Landing-Page einen Re-Login zeigt.
devUser.clear();
}
let body: unknown = null;
try {
body = await res.json();

View file

@ -21,7 +21,8 @@ export async function uploadMedia(file: File | Blob, filename?: string): Promise
form.append('file', wrapped);
const headers: Record<string, string> = {};
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',

View file

@ -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 <jwt>` 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<string | null>(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<string | null>(null);
user = $state<AuthUser | null>(null);
stubId = $state<string | null>(null); // Phase-2-Übergangs-Fallback (?stub=<uuid>)
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<void> {
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();

View file

@ -68,18 +68,18 @@
{#if devUser.id}
<a
href="/account"
class="truncate max-w-[180px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
title={devUser.id}
class="truncate max-w-[200px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
title={devUser.user?.email ?? devUser.id}
>
{devUser.id}
{devUser.user?.email ?? devUser.user?.name ?? devUser.id}
</a>
{:else}
<button
<a
href="/"
class="rounded bg-[var(--color-primary)] px-3 py-1 text-[var(--color-primary-fg)]"
onclick={() => devUser.set(prompt(t('landing.dev_user_prompt')) ?? '')}
>
{t('nav.login_dev')}
</button>
Login
</a>
{/if}
</div>
</div>

View file

@ -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<string | null>(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;
}
}
</script>
<div class="mx-auto max-w-2xl py-12 text-center">
@ -15,34 +35,49 @@
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('landing.intro')}</p>
{#if !devUser.id}
<div
class="mt-8 rounded-lg border bg-[var(--color-card)] border-[var(--color-border)] p-6 text-left"
<form
class="mt-8 rounded-lg border bg-[var(--color-card)] border-[var(--color-border)] p-6 text-left space-y-3"
onsubmit={onSubmit}
>
<h2 class="text-lg font-medium">{t('landing.cta_login')}</h2>
<form
class="mt-4 flex gap-2"
onsubmit={(e) => {
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');
}
}}
>
<h2 class="text-lg font-medium">Anmelden</h2>
<label class="block text-sm">
<span class="font-medium">E-Mail</span>
<input
name="user_id"
placeholder="u-test-1"
aria-label={t('landing.dev_user_prompt')}
class="flex-1 rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
type="email"
bind:value={email}
required
autocomplete="email"
class="mt-1 block w-full rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
/>
<button
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
type="submit">→</button
>
</form>
</div>
</label>
<label class="block text-sm">
<span class="font-medium">Passwort</span>
<input
type="password"
bind:value={password}
required
autocomplete="current-password"
class="mt-1 block w-full rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
/>
</label>
{#if error}
<p class="text-sm text-[var(--color-danger)]" role="alert">{error}</p>
{/if}
<button
type="submit"
disabled={busy || !email || !password}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
>
{busy ? '…' : 'Login'}
</button>
<p class="text-xs text-[var(--color-muted)]">
Mana-Account auf <a
href="https://auth.mana.how"
target="_blank"
rel="noopener"
class="underline">auth.mana.how</a
>. Cross-App-SSO über *.mana.how.
</p>
</form>
{/if}
</div>

View file

@ -69,10 +69,37 @@
<h1 class="text-2xl font-semibold">{t('account.title')}</h1>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-sm">
<div class="text-[var(--color-muted)]">{t('account.user_id_label')}</div>
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
</div>
{#if devUser.user}
<div class="space-y-2 text-sm">
<div>
<div class="text-[var(--color-muted)]">E-Mail</div>
<div class="mt-1 font-medium">{devUser.user.email}</div>
</div>
{#if devUser.user.name}
<div>
<div class="text-[var(--color-muted)]">Name</div>
<div class="mt-1">{devUser.user.name}</div>
</div>
{/if}
<div>
<div class="text-[var(--color-muted)]">Tier</div>
<div class="mt-1">
<span
class="inline-flex rounded bg-[var(--color-border)]/40 px-2 py-0.5 text-xs"
>{devUser.user.tier}</span>
</div>
</div>
<div>
<div class="text-[var(--color-muted)]">{t('account.user_id_label')}</div>
<code class="mt-1 block break-all text-xs text-[var(--color-muted)]">{devUser.user.id}</code>
</div>
</div>
{:else}
<div class="text-sm">
<div class="text-[var(--color-muted)]">{t('account.user_id_label')} (Stub)</div>
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
</div>
{/if}
<div class="mt-3 flex gap-3">
<button
type="button"
@ -82,7 +109,6 @@
{t('account.logout')}
</button>
</div>
<p class="mt-3 text-xs text-[var(--color-muted)]">{t('account.phase2_hint')}</p>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">