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
|
* Cards-API-Client. Dünner Fetch-Wrapper.
|
||||||
* dem Dev-Auth-Stub setzt. Phase 2 ersetzt das durch ein Bearer-Token
|
*
|
||||||
* aus @mana/shared-auth.
|
* 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';
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (devUser.id) {
|
if (devUser.token) {
|
||||||
headers['X-User-Id'] = devUser.id;
|
headers['Authorization'] = `Bearer ${devUser.token}`;
|
||||||
|
} else if (devUser.stubId) {
|
||||||
|
headers['X-User-Id'] = devUser.stubId;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: opts.method ?? 'GET',
|
method: opts.method ?? 'GET',
|
||||||
|
|
@ -43,6 +50,11 @@ export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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;
|
let body: unknown = null;
|
||||||
try {
|
try {
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export async function uploadMedia(file: File | Blob, filename?: string): Promise
|
||||||
form.append('file', wrapped);
|
form.append('file', wrapped);
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
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`, {
|
const res = await fetch(`${API_BASE}/api/v1/media/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,142 @@
|
||||||
/**
|
/**
|
||||||
* Dev-Auth-Stub. Phase 2 ersetzt diesen Layer durch echtes JWT-
|
* Auth-Session für cards-web (Phase 10c).
|
||||||
* Handling via @mana/shared-auth gegen mana-auth.
|
|
||||||
*
|
*
|
||||||
* Für jetzt: User-ID lebt in sessionStorage und wird als
|
* Echte SSO gegen mana-auth: `accessToken` (EdDSA-JWT von
|
||||||
* `X-User-Id`-Header an cards-api geschickt.
|
* `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 {
|
const TOKEN_KEY = 'cards.auth.accessToken';
|
||||||
id = $state<string | null>(null);
|
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() {
|
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') {
|
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) {
|
set(userId: string) {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
this.id = trimmed;
|
this.stubId = trimmed;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
sessionStorage.setItem('cards.dev.userId', trimmed);
|
window.sessionStorage.setItem(STUB_KEY, trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.id = null;
|
this.clearLocal();
|
||||||
|
this.stubId = null;
|
||||||
if (typeof window !== 'undefined') {
|
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}
|
{#if devUser.id}
|
||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
class="truncate max-w-[180px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
class="truncate max-w-[200px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||||
title={devUser.id}
|
title={devUser.user?.email ?? devUser.id}
|
||||||
>
|
>
|
||||||
{devUser.id}
|
{devUser.user?.email ?? devUser.user?.name ?? devUser.id}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<a
|
||||||
|
href="/"
|
||||||
class="rounded bg-[var(--color-primary)] px-3 py-1 text-[var(--color-primary-fg)]"
|
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')}
|
Login
|
||||||
</button>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,29 @@
|
||||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.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(() => {
|
onMount(() => {
|
||||||
if (devUser.id) goto('/decks');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl py-12 text-center">
|
<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>
|
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('landing.intro')}</p>
|
||||||
|
|
||||||
{#if !devUser.id}
|
{#if !devUser.id}
|
||||||
<div
|
<form
|
||||||
class="mt-8 rounded-lg border bg-[var(--color-card)] border-[var(--color-border)] p-6 text-left"
|
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>
|
<h2 class="text-lg font-medium">Anmelden</h2>
|
||||||
<form
|
<label class="block text-sm">
|
||||||
class="mt-4 flex gap-2"
|
<span class="font-medium">E-Mail</span>
|
||||||
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');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
name="user_id"
|
type="email"
|
||||||
placeholder="u-test-1"
|
bind:value={email}
|
||||||
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"
|
|
||||||
required
|
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
|
</label>
|
||||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
<label class="block text-sm">
|
||||||
type="submit">→</button
|
<span class="font-medium">Passwort</span>
|
||||||
>
|
<input
|
||||||
</form>
|
type="password"
|
||||||
</div>
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,37 @@
|
||||||
<h1 class="text-2xl font-semibold">{t('account.title')}</h1>
|
<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">
|
<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="text-[var(--color-muted)]">{t('account.user_id_label')}</div>
|
<div class="space-y-2 text-sm">
|
||||||
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
|
<div>
|
||||||
</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">
|
<div class="mt-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -82,7 +109,6 @@
|
||||||
{t('account.logout')}
|
{t('account.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-xs text-[var(--color-muted)]">{t('account.phase2_hint')}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
<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