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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue