Phase 10c: Cards-Web SSO-Login gegen mana-auth
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
a960d09e5b
commit
7119756ce6
6 changed files with 236 additions and 55 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<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');
|
||||
}
|
||||
}}
|
||||
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">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"
|
||||
/>
|
||||
</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
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
||||
type="submit">→</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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{#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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue