mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 15:41:09 +02:00
feat(mana): migrate to central auth portal — no embedded login UI, clean cut
managarten redet jetzt nicht mehr direkt mit Better-Auth — Login,
Register, Passwort-Reset, 2FA-Verify, Magic-Link, Passkey-Login laufen
ALLE über `auth.mana.how` (mana-auth-web portal). managarten ist nur
noch Consumer einer existierenden Session.
## Architektur
- Unauthenticated: `redirectToPortal({ next })` macht hartes Redirect zu
`auth.mana.how/login?app=mana&redirect=<callback>`. AuthGate
(`(app)/+layout.svelte`) und `require-auth` triggern das.
- Nach Login: Portal setzt SSO-Cookie auf `.mana.how`. Browser landet
auf `/auth/callback?next=<deep-link>`.
- Callback: `session.tryRefresh()` holt frischen JWT via Cookie,
`loadUserFromToken()` setzt User, `goto(next)` renderet (app)-Layout
mit unlocked Vault (Root-Layout-$effect feuert auf User-ID-Wechsel).
## Files
NEU:
- `lib/auth/portal-redirect.ts` — Helper für Portal-URL-Bau + hard redirect.
- `lib/auth/session.svelte.ts` — schlanke Session-Klasse: Token-Refresh
via SSO-Cookie, ensureFresh, signOut. Storage: `mana.auth.accessToken`,
`mana.auth.user`.
- `lib/auth/settings-client.ts` — Passkey-CRUD, 2FA-Setup, Sessions,
Audit-Events. Pflegt keinen State, ruft direkt mana-auth API.
GELÖSCHT:
- `routes/(auth)/login|register|forgot-password|reset-password|+layout`
- `routes/auth/reset-password` (war Alias-Redirect)
- Komplette `(auth)` route group.
UMGESCHRIEBEN:
- `lib/stores/auth.svelte.ts` — re-exportiert `session` als `authStore`
(keine 47-Methoden-Factory aus `@mana/shared-auth-ui` mehr).
- `routes/auth/callback/+page.svelte` — Token-Refresh + Deep-Link statt
Legacy-Supabase-Stub.
- `lib/components/settings/sections/SecuritySection.svelte` — alle
`authStore.registerPasskey/enableTwoFactor/...` Calls auf neuen
`settings-client` umgelenkt. UI-Komponenten (PasskeyManager,
TwoFactorSetup, …) aus `@mana/shared-auth-ui` bleiben — sind reine
Render-Components.
ANGEPASST (Portal-Redirect statt `goto('/login')`):
- `(app)/+layout.svelte`, `RouteTierGate`, `email-verified`,
`verification-failed`, `feedback/+layout`, `quotes/lists`,
`quotes/favorites`, `citycorners/favorites`, `feedback/DetailView`,
`feedback/ListView`, `profile/ListView`, `guest-prompt`,
`require-auth.svelte.ts`.
ENV:
- `.env.development`: `MANA_AUTH_WEB_URL=http://localhost:3002`.
- `scripts/generate-env.mjs`: schreibt `PUBLIC_MANA_AUTH_URL` +
`PUBLIC_AUTH_WEB_URL` ins `apps/mana/apps/web/.env`.
## Status
- `pnpm run check`: 0 errors, 0 warnings, 7672 files.
- `pnpm build` (8 GB heap): grün.
- E2E lokal + Production-Deploy stehen aus — Plan siehe
`mana/docs/playbooks/MANAGARTEN_AUTH_PORTAL_MIGRATION.md`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d3d9271426
commit
5635598a58
27 changed files with 773 additions and 489 deletions
|
|
@ -23,6 +23,10 @@ PUBLIC_GLITCHTIP_DSN=
|
|||
|
||||
# Mana Core Auth Service
|
||||
MANA_AUTH_URL=http://localhost:3001
|
||||
# Auth-Portal-UI (Login/Register/Reset, getrennt vom Auth-API-Service).
|
||||
# In Prod identisch mit MANA_AUTH_URL (nginx splittet /api/* zu mana-auth,
|
||||
# Rest zu mana-auth-web), lokal aber eigener Port (mana-auth-web :3002).
|
||||
MANA_AUTH_WEB_URL=http://localhost:3002
|
||||
# Mana Credits Service
|
||||
MANA_CREDITS_URL=http://localhost:3061
|
||||
# Mana Media Service (CAS, thumbnails, Photos gallery)
|
||||
|
|
|
|||
71
apps/mana/apps/web/src/lib/auth/portal-redirect.ts
Normal file
71
apps/mana/apps/web/src/lib/auth/portal-redirect.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Portal-Redirect-Helper.
|
||||
*
|
||||
* Statt einer eigenen Login-Seite leitet managarten unauthenticated User
|
||||
* an `auth.mana.how/login` weiter. Nach erfolgreichem Login landet der
|
||||
* User auf `/auth/callback?next=…`, wo der Token via SSO-Cookie geholt
|
||||
* und der Encryption-Vault entsperrt wird.
|
||||
*
|
||||
* In Dev: `PUBLIC_AUTH_WEB_URL=http://localhost:3002`.
|
||||
* In Prod: `PUBLIC_AUTH_WEB_URL=https://auth.mana.how`.
|
||||
*
|
||||
* Diese Funktion macht einen harten `window.location.href` — keinen
|
||||
* SvelteKit-`goto` — weil das Portal eine eigene Origin ist.
|
||||
*/
|
||||
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
const FALLBACK_PORTAL = 'https://auth.mana.how';
|
||||
const APP_ID = 'mana';
|
||||
|
||||
function portalUrl(): string {
|
||||
return publicEnv.PUBLIC_AUTH_WEB_URL ?? FALLBACK_PORTAL;
|
||||
}
|
||||
|
||||
function appOrigin(): string {
|
||||
if (typeof window === 'undefined') return 'https://mana.how';
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
/** Baut die `/auth/callback?next=<path>`-URL für die aktuelle App. */
|
||||
function callbackUrl(next: string | null | undefined): string {
|
||||
const base = appOrigin();
|
||||
const nextParam = next && next.startsWith('/') ? `?next=${encodeURIComponent(next)}` : '';
|
||||
return `${base}/auth/callback${nextParam}`;
|
||||
}
|
||||
|
||||
export interface RedirectToPortalOptions {
|
||||
/**
|
||||
* Pfad innerhalb der App, zu dem der User nach Login zurückgeführt
|
||||
* werden soll. Default: der aktuelle `window.location.pathname`.
|
||||
* Pfade ohne führenden `/` werden ignoriert.
|
||||
*/
|
||||
next?: string;
|
||||
/** Welche Portal-Page direkt öffnen — login (default) oder register. */
|
||||
target?: 'login' | 'register';
|
||||
}
|
||||
|
||||
/** Hartes Redirect zum Auth-Portal. */
|
||||
export function redirectToPortal(options: RedirectToPortalOptions = {}): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const target = options.target ?? 'login';
|
||||
const next = options.next ?? window.location.pathname + window.location.search;
|
||||
const url = new URL(`${portalUrl()}/${target}`);
|
||||
url.searchParams.set('app', APP_ID);
|
||||
url.searchParams.set('redirect', callbackUrl(next));
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Portal-URL als String (z.B. für `<a href>` in
|
||||
* server-rendered Content). Vorsicht: Bei Klick erfolgt ein voller
|
||||
* Navigation-Refresh (App-Bundle wird neu geladen).
|
||||
*/
|
||||
export function portalHref(options: RedirectToPortalOptions = {}): string {
|
||||
const target = options.target ?? 'login';
|
||||
const next = options.next ?? (typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||
const url = new URL(`${portalUrl()}/${target}`);
|
||||
url.searchParams.set('app', APP_ID);
|
||||
url.searchParams.set('redirect', callbackUrl(next));
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@
|
|||
* recovery UX. Mixing the two would muddy the message.
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
|
||||
/** What requireAuth() needs to render the modal. */
|
||||
export interface RequireAuthOptions {
|
||||
|
|
@ -129,10 +129,12 @@ export const authGateState = new AuthGateState();
|
|||
* - If guest → shows a modal and resolves to `true` if the user
|
||||
* logs in (and returns to the page), `false` if they cancel.
|
||||
*
|
||||
* The modal navigates to `/login?next=<current path>` so the user
|
||||
* lands back on the same view after logging in. The Promise then
|
||||
* resolves on the *next* time `authStore.isAuthenticated` flips to
|
||||
* `true` — the caller does NOT have to re-trigger their action.
|
||||
* The modal navigates to `auth.mana.how/login?app=mana&redirect=<callback>`
|
||||
* so the user lands back on `/auth/callback?next=<current path>` after
|
||||
* the Portal-Login, where Token-Refresh + Vault-Unlock laufen, bevor
|
||||
* `goto(next)` ins App-Innere weiterleitet. Die Promise resolved beim
|
||||
* nächsten Mal, wenn `authStore.isAuthenticated` auf `true` flippt — der
|
||||
* Aufrufer muss seine Action NICHT manuell re-triggern.
|
||||
*/
|
||||
export async function requireAuth(options: RequireAuthOptions): Promise<boolean> {
|
||||
if (authStore.isAuthenticated) return true;
|
||||
|
|
@ -140,19 +142,16 @@ export async function requireAuth(options: RequireAuthOptions): Promise<boolean>
|
|||
}
|
||||
|
||||
/**
|
||||
* Called by AuthRequiredModal when the user clicks "Anmelden". Saves
|
||||
* the current path so /login can redirect back, then navigates.
|
||||
* Called by AuthRequiredModal when the user clicks "Anmelden". Hartes
|
||||
* Redirect zum Auth-Portal — der aktuelle Pfad geht als `next`-Hint mit,
|
||||
* damit der User nach Login + Vault-Unlock wieder hier landet.
|
||||
*
|
||||
* The modal closes immediately on click. We deliberately do NOT wait
|
||||
* for the post-login redirect to come back here — once the user
|
||||
* navigates to /login, the original action's call site has lost its
|
||||
* stack frame anyway. Instead, the user re-clicks the button after
|
||||
* landing back on the page; the second click sees `isAuthenticated`
|
||||
* is now true and proceeds without a modal.
|
||||
* `authGateState.resolve(false)` läuft VOR dem `window.location.href`-
|
||||
* Wechsel, weil der Modal-Caller den Promise sonst nie auflöst — der
|
||||
* SvelteKit-Run-Loop wird durch das harte Redirect abgebrochen.
|
||||
*/
|
||||
export async function navigateToLogin(): Promise<void> {
|
||||
const here = page.url?.pathname ?? '/';
|
||||
const next = here === '/login' ? '/' : here;
|
||||
await goto(`/login?next=${encodeURIComponent(next)}`);
|
||||
authGateState.resolve(false);
|
||||
redirectToPortal({ next: here });
|
||||
}
|
||||
|
|
|
|||
271
apps/mana/apps/web/src/lib/auth/session.svelte.ts
Normal file
271
apps/mana/apps/web/src/lib/auth/session.svelte.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* Mana Session — schlanker Auth-Client für die App-Seite.
|
||||
*
|
||||
* **Was diese Klasse macht:** Token-Lifecycle managen (Refresh via
|
||||
* SSO-Cookie, lokale Persistierung im localStorage, proaktive
|
||||
* Auffrischung vor Ablauf, Logout). Sie macht KEINE Login/Register-
|
||||
* Flows — die laufen exklusiv über das zentrale Auth-Portal
|
||||
* (`auth.mana.how`).
|
||||
*
|
||||
* **Wie der Flow läuft:**
|
||||
* 1. Unauthenticated User landet auf `/` → `+page.svelte` redirected
|
||||
* zum Portal mit `?app=mana&redirect=<callback>`.
|
||||
* 2. User loggt sich auf dem Portal ein. Better-Auth setzt
|
||||
* `mana_session` Cookie auf `.mana.how`.
|
||||
* 3. Browser wird zurück zur App geleitet (`/auth/callback`).
|
||||
* 4. `auth/callback/+page.svelte` ruft `session.tryRefresh()`.
|
||||
* Der POST geht mit `credentials: 'include'` an
|
||||
* `${MANA_AUTH_URL}/api/v1/auth/refresh` — der Browser sendet das
|
||||
* SSO-Cookie automatisch mit. mana-auth liest das Cookie,
|
||||
* validiert die Session, mintet einen frischen JWT und gibt ihn
|
||||
* als JSON zurück.
|
||||
* 5. Token landet im `localStorage` (`mana.auth.accessToken`), das
|
||||
* User-Profil (aus den JWT-Claims) in `mana.auth.user`.
|
||||
* 6. Vor jedem API-Call ruft der Caller `session.ensureFresh()` auf
|
||||
* — wenn weniger als 60s Restlaufzeit, wird automatisch refreshed.
|
||||
*
|
||||
* **Was diese Klasse NICHT macht:** Settings-Methoden (Passkey-CRUD,
|
||||
* 2FA-Setup, Sessions-Liste, Audit-Log). Die leben in
|
||||
* `lib/auth/settings-client.ts` und nutzen den Token, den die
|
||||
* Session vorhält.
|
||||
*/
|
||||
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
const TOKEN_KEY = 'mana.auth.accessToken';
|
||||
const USER_KEY = 'mana.auth.user';
|
||||
|
||||
const FALLBACK_AUTH_URL = 'https://auth.mana.how';
|
||||
|
||||
function authBaseUrl(): string {
|
||||
return publicEnv.PUBLIC_MANA_AUTH_URL ?? FALLBACK_AUTH_URL;
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
tier: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface JwtClaims {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
tier?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
function decodeJwt(token: string): JwtClaims | null {
|
||||
try {
|
||||
const [, payload] = token.split('.');
|
||||
if (!payload) return null;
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const json =
|
||||
typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8');
|
||||
return JSON.parse(json) as JwtClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(claims: JwtClaims | null): boolean {
|
||||
if (!claims?.exp) return false;
|
||||
return claims.exp * 1000 <= Date.now();
|
||||
}
|
||||
|
||||
function userFromClaims(claims: JwtClaims): SessionUser {
|
||||
return {
|
||||
id: claims.sub,
|
||||
email: claims.email ?? '',
|
||||
name: claims.name ?? null,
|
||||
role: claims.role ?? 'user',
|
||||
tier: claims.tier ?? 'public',
|
||||
twoFactorEnabled: claims.twoFactorEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
class Session {
|
||||
token = $state<string | null>(null);
|
||||
user = $state<SessionUser | null>(null);
|
||||
initialized = $state(false);
|
||||
loading = $state(false);
|
||||
|
||||
private refreshing: Promise<boolean> | null = null;
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return !!this.user && !!this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einmaliger Boot-Pass: lädt persistierte Tokens, validiert sie,
|
||||
* versucht stillschweigend eine SSO-Refresh wenn der lokale Token
|
||||
* abgelaufen ist (Cookie könnte noch leben).
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
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;
|
||||
try {
|
||||
this.user = JSON.parse(userJson) as SessionUser;
|
||||
} catch {
|
||||
this.user = userFromClaims(claims);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (claims) {
|
||||
// JWT abgelaufen — versuche stillschweigend zu refreshen
|
||||
// (SSO-Cookie kann noch leben).
|
||||
try {
|
||||
this.user = JSON.parse(userJson) as SessionUser;
|
||||
} catch {
|
||||
this.user = userFromClaims(claims);
|
||||
}
|
||||
const ok = await this.tryRefresh();
|
||||
if (!ok) this.clearLocal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Kein Token lokal — letzter Versuch: vielleicht haben wir
|
||||
// trotzdem ein SSO-Cookie (z.B. anderer Tab hat eingeloggt).
|
||||
await this.tryRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen frischen JWT vom Server. Benötigt das SSO-Cookie
|
||||
* (wird vom Browser automatisch mitgesendet).
|
||||
*
|
||||
* Returns `true` wenn der Refresh erfolgreich war, sonst `false`.
|
||||
* Bei `false` ist die Session geleert worden.
|
||||
*/
|
||||
async tryRefresh(): Promise<boolean> {
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { accessToken?: string };
|
||||
if (!data.accessToken) return false;
|
||||
this.token = data.accessToken;
|
||||
const claims = decodeJwt(data.accessToken);
|
||||
if (claims) {
|
||||
this.user = userFromClaims(claims);
|
||||
}
|
||||
this.persist();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
this.refreshing = null;
|
||||
}
|
||||
})();
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das User-Profil aus dem aktuell gehaltenen Token. Aufrufen
|
||||
* direkt nach `tryRefresh()` im Callback-Handler, damit `user` synchron
|
||||
* gesetzt ist bevor das Layout rendert.
|
||||
*/
|
||||
loadUserFromToken(): void {
|
||||
if (!this.token) return;
|
||||
const claims = decodeJwt(this.token);
|
||||
if (!claims) return;
|
||||
this.user = userFromClaims(claims);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Liefert den aktuellen Token. Refresht silently bei nahem Ablauf. */
|
||||
async getValidToken(): Promise<string | null> {
|
||||
await this.ensureFresh();
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/** Synchroner Getter — kein Refresh. Nutzt das, was lokal liegt. */
|
||||
getAccessToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/** Wenn der Token <60s Restlaufzeit hat: refreshen. */
|
||||
async ensureFresh(): Promise<void> {
|
||||
if (!this.token) return;
|
||||
const claims = decodeJwt(this.token);
|
||||
if (!claims?.exp) return;
|
||||
const remainingMs = claims.exp * 1000 - Date.now();
|
||||
if (remainingMs < 60_000) {
|
||||
await this.tryRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout. Ruft den Portal-Endpoint zum Cookie-Invalidieren auf
|
||||
* (best-effort), löscht lokale Tokens.
|
||||
*
|
||||
* Der Caller ist verantwortlich, danach den Redirect zum Portal
|
||||
* zu machen (`redirectToPortal()`) — die Session selbst macht keine
|
||||
* Navigation.
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
const had = !!this.token;
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this.clearLocal();
|
||||
if (had && typeof window !== 'undefined') {
|
||||
try {
|
||||
await fetch(`${authBaseUrl()}/api/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — auch ohne Server-Logout ist der lokale
|
||||
// Token weg und der Caller leitet eh zum Portal weiter.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Komplett-Reset ohne Server-Call (z.B. bei API-401). */
|
||||
clear(): void {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this.clearLocal();
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (this.token) window.localStorage.setItem(TOKEN_KEY, this.token);
|
||||
else window.localStorage.removeItem(TOKEN_KEY);
|
||||
if (this.user) window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
|
||||
else window.localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
private clearLocal(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
window.localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton — die ganze App nutzt diese Instanz. */
|
||||
export const session = new Session();
|
||||
|
||||
/** Internal helper für andere Auth-Module (settings-client, vault-unlock). */
|
||||
export function _authBaseUrl(): string {
|
||||
return authBaseUrl();
|
||||
}
|
||||
248
apps/mana/apps/web/src/lib/auth/settings-client.ts
Normal file
248
apps/mana/apps/web/src/lib/auth/settings-client.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Settings-Client — Account-Settings-Operationen (Passkey-CRUD,
|
||||
* 2FA-Setup, Sessions-Liste, Audit-Log).
|
||||
*
|
||||
* Diese Methoden brauchen einen authentisierten User. Sie ziehen
|
||||
* den Token aus dem `session`-Singleton (`session.svelte.ts`).
|
||||
*
|
||||
* Alle Calls gehen gegen `mana-auth` (BASE_URL aus
|
||||
* `PUBLIC_MANA_AUTH_URL`). Cookies werden mitgesendet (Better-Auth
|
||||
* verwaltet zusätzlich zur JWT-Auth den Session-State auch im
|
||||
* Cookie für CSRF / Re-Auth-Flows).
|
||||
*/
|
||||
|
||||
import { _authBaseUrl, session } from './session.svelte';
|
||||
|
||||
async function authHeaders(): Promise<HeadersInit> {
|
||||
const token = await session.getValidToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function expectOk(res: Response, op: string): Promise<unknown> {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body === 'object' && body && 'message' in body
|
||||
? String((body as { message: unknown }).message)
|
||||
: `${op} failed (HTTP ${res.status})`
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
// ─── Passkeys ────────────────────────────────────────────────
|
||||
|
||||
/** Shape, der von der UI (`PasskeyManager` aus `@mana/shared-auth-ui`) erwartet wird. */
|
||||
export interface PasskeyEntry {
|
||||
id: string;
|
||||
credentialId: string;
|
||||
deviceType: string;
|
||||
backedUp: boolean;
|
||||
friendlyName: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PasskeyCapability {
|
||||
browser: boolean;
|
||||
conditionalUI: boolean;
|
||||
server: boolean;
|
||||
available: boolean;
|
||||
rpId: string | null;
|
||||
}
|
||||
|
||||
export const passkeys = {
|
||||
async list(): Promise<PasskeyEntry[]> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys`, {
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
const data = (await expectOk(res, 'passkeys.list')) as { passkeys?: PasskeyEntry[] };
|
||||
return data?.passkeys ?? [];
|
||||
},
|
||||
|
||||
async capability(): Promise<PasskeyCapability> {
|
||||
const base = _authBaseUrl();
|
||||
const res = await fetch(`${base}/api/v1/auth/passkeys/capability`);
|
||||
if (!res.ok) {
|
||||
return { browser: false, conditionalUI: false, server: false, available: false, rpId: null };
|
||||
}
|
||||
const data = (await res.json()) as { enabled?: boolean; rpId?: string | null };
|
||||
const browser = typeof window !== 'undefined' && 'PublicKeyCredential' in window;
|
||||
let conditionalUI = false;
|
||||
if (browser) {
|
||||
try {
|
||||
const PKC = (window as unknown as { PublicKeyCredential: typeof PublicKeyCredential })
|
||||
.PublicKeyCredential;
|
||||
if (typeof PKC.isConditionalMediationAvailable === 'function') {
|
||||
conditionalUI = await PKC.isConditionalMediationAvailable();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const server = !!data.enabled;
|
||||
return {
|
||||
browser,
|
||||
conditionalUI,
|
||||
server,
|
||||
available: browser && server,
|
||||
rpId: data.rpId ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
async register(friendlyName?: string): Promise<void> {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||
const headers = await authHeaders();
|
||||
|
||||
const optionsRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/options`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
const webauthnOptions = (await expectOk(optionsRes, 'passkeys.register.options')) as Parameters<
|
||||
typeof startRegistration
|
||||
>[0]['optionsJSON'];
|
||||
|
||||
const credential = await startRegistration({ optionsJSON: webauthnOptions });
|
||||
|
||||
const verifyRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/verify`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify({ response: credential, name: friendlyName }),
|
||||
});
|
||||
await expectOk(verifyRes, 'passkeys.register.verify');
|
||||
},
|
||||
|
||||
async delete(passkeyId: string): Promise<void> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
await expectOk(res, 'passkeys.delete');
|
||||
},
|
||||
|
||||
async rename(passkeyId: string, friendlyName: string): Promise<void> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify({ name: friendlyName }),
|
||||
});
|
||||
await expectOk(res, 'passkeys.rename');
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 2FA (TOTP + Backup Codes) ───────────────────────────────
|
||||
|
||||
export interface TwoFactorEnableResponse {
|
||||
secret: string;
|
||||
uri: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface BackupCodesResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export const twoFactor = {
|
||||
async enable(password: string): Promise<TwoFactorEnableResponse> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/enable`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = (await expectOk(res, 'twoFactor.enable')) as TwoFactorEnableResponse;
|
||||
// Refresh Token so der JWT den neuen `twoFactorEnabled`-Claim trägt.
|
||||
await session.tryRefresh();
|
||||
return data;
|
||||
},
|
||||
|
||||
async disable(password: string): Promise<void> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/disable`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
await expectOk(res, 'twoFactor.disable');
|
||||
await session.tryRefresh();
|
||||
},
|
||||
|
||||
async generateBackupCodes(password: string): Promise<BackupCodesResponse> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/generate-backup-codes`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return (await expectOk(res, 'twoFactor.generateBackupCodes')) as BackupCodesResponse;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Sessions ────────────────────────────────────────────────
|
||||
|
||||
/** Shape, der von der UI (`SessionManager` aus `@mana/shared-auth-ui`) erwartet wird. */
|
||||
export interface SessionEntry {
|
||||
id: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
deviceId: string | null;
|
||||
deviceName: string | null;
|
||||
lastActivityAt: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const sessions = {
|
||||
async list(): Promise<SessionEntry[]> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions`, {
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
const data = (await expectOk(res, 'sessions.list')) as { sessions?: SessionEntry[] };
|
||||
return data?.sessions ?? [];
|
||||
},
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
await expectOk(res, 'sessions.revoke');
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Audit / Security Events ─────────────────────────────────
|
||||
|
||||
/** Shape, der von der UI (`AuditLog` aus `@mana/shared-auth-ui`) erwartet wird. */
|
||||
export interface SecurityEvent {
|
||||
id: string;
|
||||
eventType: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
metadata: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
async getSecurityEvents(limit = 50): Promise<SecurityEvent[]> {
|
||||
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/security-events?limit=${limit}`, {
|
||||
credentials: 'include',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
const data = (await expectOk(res, 'audit.getSecurityEvents')) as {
|
||||
events?: SecurityEvent[];
|
||||
};
|
||||
return data?.events ?? [];
|
||||
},
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style:border-color="hsl(var(--color-border, 0 0% 90%))"
|
||||
style:color="hsl(var(--color-foreground, 0 0% 9%))"
|
||||
onclick={() => goto('/login')}
|
||||
onclick={() => redirectToPortal()}
|
||||
>
|
||||
{isDE ? 'Anmelden' : 'Sign in'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,25 +4,66 @@
|
|||
import { ShieldCheck } from '@mana/shared-icons';
|
||||
import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import {
|
||||
passkeys as passkeysClient,
|
||||
sessions as sessionsClient,
|
||||
twoFactor as twoFactorClient,
|
||||
audit as auditClient,
|
||||
type PasskeyEntry,
|
||||
type SessionEntry,
|
||||
type SecurityEvent,
|
||||
} from '$lib/auth/settings-client';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
|
||||
import VaultSection from './VaultSection.svelte';
|
||||
|
||||
let passkeys = $state<any[]>([]);
|
||||
let sessions = $state<any[]>([]);
|
||||
let passkeys = $state<PasskeyEntry[]>([]);
|
||||
let passkeyAvailable = $state(false);
|
||||
let sessions = $state<SessionEntry[]>([]);
|
||||
let sessionsLoading = $state(false);
|
||||
let securityEvents = $state<any[]>([]);
|
||||
let securityEvents = $state<SecurityEvent[]>([]);
|
||||
let securityEventsLoading = $state(false);
|
||||
|
||||
// Adapter: die UI-Komponenten erwarten `{ success, error? }`-Returns,
|
||||
// unsere Settings-Client-Methoden werfen. Hier einmal zentral übersetzen.
|
||||
function asResult<T>(p: Promise<T>): Promise<{ success: boolean; error?: string }> {
|
||||
return p
|
||||
.then(() => ({ success: true as const }))
|
||||
.catch((e: unknown) => ({
|
||||
success: false as const,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleEnable(password: string) {
|
||||
try {
|
||||
const r = await twoFactorClient.enable(password);
|
||||
return { success: true as const, totpURI: r.uri, backupCodes: r.backupCodes };
|
||||
} catch (e) {
|
||||
return { success: false as const, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBackupCodes(password: string) {
|
||||
try {
|
||||
const r = await twoFactorClient.generateBackupCodes(password);
|
||||
return { success: true as const, backupCodes: r.backupCodes };
|
||||
} catch (e) {
|
||||
return { success: false as const, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
try {
|
||||
passkeys = await authStore.listPasskeys();
|
||||
const cap = await passkeysClient.capability();
|
||||
passkeyAvailable = cap.available;
|
||||
passkeys = await passkeysClient.list();
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessions = await sessionsClient.list();
|
||||
sessionsLoading = false;
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEvents = await auditClient.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
} catch (e) {
|
||||
console.error('SecuritySection load failed:', e);
|
||||
|
|
@ -44,12 +85,12 @@
|
|||
<SettingsPanel id="passkeys">
|
||||
<PasskeyManager
|
||||
{passkeys}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onRegister={(name) => authStore.registerPasskey(name)}
|
||||
onDelete={(id) => authStore.deletePasskey(id)}
|
||||
onRename={(id, name) => authStore.renamePasskey(id, name)}
|
||||
{passkeyAvailable}
|
||||
onRegister={(name) => asResult(passkeysClient.register(name))}
|
||||
onDelete={(id) => asResult(passkeysClient.delete(id))}
|
||||
onRename={(id, name) => asResult(passkeysClient.rename(id, name))}
|
||||
onRefresh={async () => {
|
||||
passkeys = await authStore.listPasskeys();
|
||||
passkeys = await passkeysClient.list();
|
||||
}}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
/>
|
||||
|
|
@ -59,10 +100,10 @@
|
|||
<SessionManager
|
||||
{sessions}
|
||||
loading={sessionsLoading}
|
||||
onRevoke={(id) => authStore.revokeSession(id)}
|
||||
onRevoke={(id) => asResult(sessionsClient.revoke(id))}
|
||||
onRefresh={async () => {
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessions = await sessionsClient.list();
|
||||
sessionsLoading = false;
|
||||
}}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
|
|
@ -72,9 +113,9 @@
|
|||
<SettingsPanel id="two-factor">
|
||||
<TwoFactorSetup
|
||||
enabled={!!authStore.user?.twoFactorEnabled}
|
||||
onEnable={(password) => authStore.enableTwoFactor(password)}
|
||||
onDisable={(password) => authStore.disableTwoFactor(password)}
|
||||
onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)}
|
||||
onEnable={handleEnable}
|
||||
onDisable={(password) => asResult(twoFactorClient.disable(password))}
|
||||
onGenerateBackupCodes={handleGenerateBackupCodes}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
|
@ -89,7 +130,7 @@
|
|||
loading={securityEventsLoading}
|
||||
onRefresh={async () => {
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEvents = await auditClient.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
}}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import ItemCard from '../components/ItemCard.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { portalHref } from '$lib/auth/portal-redirect';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
|
|
@ -118,7 +119,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<p class="login-hint">
|
||||
<a href="/login" class="link">Login</a>, um zu antworten.
|
||||
<a href={portalHref()} class="link">Login</a>, um zu antworten.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { useCommunityFeed, toggleReactionOnItem } from '../queries.svelte';
|
||||
import ItemCard from '../components/ItemCard.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { portalHref } from '$lib/auth/portal-redirect';
|
||||
|
||||
interface Props {
|
||||
/** Optional initial moduleContext filter — passed by the
|
||||
|
|
@ -82,7 +83,7 @@
|
|||
Noch keine Stimmen — sei der erste, der was reinwirft.
|
||||
{#if !authStore.user}
|
||||
<br />
|
||||
<a href="/login" class="link">Login</a>, um mitzumachen.
|
||||
<a href={portalHref()} class="link">Login</a>, um mitzumachen.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
import {
|
||||
EditProfileModal,
|
||||
ChangePasswordModal,
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
async function handleAccountDeleted() {
|
||||
toast.info($_('profile.hub.toast_account_deleting'));
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
redirectToPortal({ next: '/' });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -232,7 +233,7 @@
|
|||
class="account-btn"
|
||||
onclick={async () => {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
redirectToPortal({ next: '/' });
|
||||
}}
|
||||
>
|
||||
{$_('profile.logout')}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
* Auth Store — re-export der neuen Session-Klasse.
|
||||
*
|
||||
* Bis 2026-05-12 war das ein Wrapper über `createManaAuthStore()` aus
|
||||
* `@mana/shared-auth-ui`, der 47 Methoden mitbrachte (Login, Register,
|
||||
* Reset, Passkey-CRUD, 2FA-Setup, Sessions, Audit). Mit dem Wechsel
|
||||
* auf den zentralen Auth-Portal sind die Login-/Register-/Reset-Flows
|
||||
* komplett rausgeflogen — sie leben jetzt auf `auth.mana.how`.
|
||||
*
|
||||
* Was bleibt:
|
||||
* - `authStore.user`, `authStore.isAuthenticated`, `authStore.initialized`,
|
||||
* `authStore.loading` — der reaktive Session-State.
|
||||
* - `authStore.initialize()` — Boot-Pass.
|
||||
* - `authStore.signOut()` — Logout (kein Redirect; den macht der Caller).
|
||||
* - `authStore.getValidToken()`, `authStore.getAccessToken()` — Token-Access.
|
||||
*
|
||||
* Was wegfällt (Settings → `$lib/auth/settings-client.ts`):
|
||||
* - Passkey-CRUD → `import { passkeys } from '$lib/auth/settings-client'`
|
||||
* - 2FA-Setup → `import { twoFactor } from '$lib/auth/settings-client'`
|
||||
* - Sessions / Audit → `import { sessions, audit } from '$lib/auth/settings-client'`
|
||||
*
|
||||
* Was ganz wegfällt (passiert jetzt im Portal):
|
||||
* - `signIn`, `signUp`, `resetPassword`, `resetPasswordWithToken`,
|
||||
* `resendVerificationEmail`, `verifyTwoFactor`, `verifyBackupCode`,
|
||||
* `sendMagicLink`, `signInWithPasskey`.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@mana/shared-auth-ui';
|
||||
|
||||
export const authStore = createManaAuthStore();
|
||||
export { session as authStore } from '$lib/auth/session.svelte';
|
||||
|
|
|
|||
|
|
@ -25,12 +25,15 @@
|
|||
*/
|
||||
|
||||
import type { BottomNotification } from '@mana/shared-ui';
|
||||
import { portalHref } from '$lib/auth/portal-redirect';
|
||||
|
||||
let prompts = $state<BottomNotification[]>([]);
|
||||
|
||||
/** Default action target — the login page already exposes both login
|
||||
* and register flows so we point at one URL and let the user pick. */
|
||||
const DEFAULT_LOGIN_HREF = '/login';
|
||||
/** Default action target — the central auth portal (auth.mana.how) handles
|
||||
* both login and register flows in one UI. */
|
||||
function defaultLoginHref(): string {
|
||||
return portalHref();
|
||||
}
|
||||
|
||||
/** Navigates the browser. Kept as a small wrapper so unit tests can
|
||||
* swap it out without pulling SvelteKit's `goto`. */
|
||||
|
|
@ -79,7 +82,7 @@ export const guestPrompt = {
|
|||
action: {
|
||||
label: actionLabel,
|
||||
onClick: () => {
|
||||
navigate(DEFAULT_LOGIN_HREF);
|
||||
navigate(defaultLoginHref());
|
||||
guestPrompt.dismiss(id);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { redirectToPortal, portalHref } from '$lib/auth/portal-redirect';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import FeedbackQuickModal from '$lib/components/feedback/FeedbackQuickModal.svelte';
|
||||
|
|
@ -497,7 +498,7 @@
|
|||
guestMode?.destroy();
|
||||
setErrorTrackingUser(null);
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
redirectToPortal({ next: '/' });
|
||||
}
|
||||
|
||||
// ── Guest Mode ──────────────────────────────────────────
|
||||
|
|
@ -739,7 +740,7 @@
|
|||
markAsGuest();
|
||||
guestMode = createGuestMode('mana', {
|
||||
nudgeDelayMinutes: 3,
|
||||
onRegister: () => goto('/register'),
|
||||
onRegister: () => redirectToPortal({ target: 'register' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1062,7 +1063,7 @@
|
|||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
loginHref="/login"
|
||||
loginHref={portalHref()}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
showAppSwitcher={false}
|
||||
showAiTierSelector={true}
|
||||
|
|
@ -1152,8 +1153,8 @@
|
|||
appId="mana"
|
||||
visible={guestMode.showWelcome}
|
||||
onClose={() => guestMode?.dismissWelcome()}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
onLogin={() => redirectToPortal()}
|
||||
onRegister={() => redirectToPortal({ target: 'register' })}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { Heart } from '@mana/shared-icons';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { portalHref } from '$lib/auth/portal-redirect';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import {
|
||||
favoritesStore,
|
||||
|
|
@ -38,7 +39,7 @@
|
|||
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
|
||||
<p class="mb-4 text-foreground-secondary">{$_('favorites.loginRequired')}</p>
|
||||
<a
|
||||
href="/login"
|
||||
href={portalHref()}
|
||||
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{$_('settings.login')}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
|
||||
import { type Favorite } from '$lib/modules/quotes/queries';
|
||||
import { getQuoteById, getQuoteText, type Quote } from '@quotes/content';
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
<User size={20} class="mx-auto text-foreground-muted mb-4" />
|
||||
<p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p>
|
||||
<button
|
||||
onclick={() => goto('/login')}
|
||||
onclick={() => redirectToPortal()}
|
||||
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$_('auth.login')}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { listsStore } from '$lib/modules/quotes/stores/lists.svelte';
|
||||
import { type QuoteList } from '$lib/modules/quotes/queries';
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</div>
|
||||
<p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p>
|
||||
<button
|
||||
onclick={() => goto('/login')}
|
||||
onclick={() => redirectToPortal()}
|
||||
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$_('auth.login')}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { Sun, Moon } from '@mana/shared-icons';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
let isDark = $derived(theme.isDark);
|
||||
// If user is already authenticated when visiting auth pages, redirect to home
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Theme toggle for auth pages -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => theme.toggleMode()}
|
||||
class="fixed top-4 right-4 z-50 rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{#if isDark}
|
||||
<Sun size={20} />
|
||||
{:else}
|
||||
<Moon size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ForgotPasswordPage } from '@mana/shared-auth-ui';
|
||||
import { ManaLogo } from '@mana/shared-branding';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
async function handleForgotPassword(email: string) {
|
||||
// resetPassword is the wrapper's "send-the-email" entry point.
|
||||
// resetPasswordWithToken is the actually-perform-the-reset step.
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Mana"
|
||||
logo={ManaLogo}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
onForgotPassword={handleForgotPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
>
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@mana/shared-auth-ui';
|
||||
import { ManaLogo } from '@mana/shared-branding';
|
||||
import { getLoginTranslations } from '@mana/shared-i18n';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { APP_VERSION, BUILD_TIME } from '$lib/version';
|
||||
|
||||
const isDark = $derived(theme.isDark);
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
||||
// Read verification status from query params (set after email verification)
|
||||
const verified = $derived($page.url.searchParams.get('verified') === 'true');
|
||||
const initialEmail = $derived($page.url.searchParams.get('email') || '');
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Mana"
|
||||
logo={ManaLogo}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.passkeyAvailable === true}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
|
||||
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
|
||||
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
{translations}
|
||||
{verified}
|
||||
{initialEmail}
|
||||
{isDark}
|
||||
version={APP_VERSION}
|
||||
buildTime={BUILD_TIME}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@mana/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@mana/shared-i18n';
|
||||
import { ManaLogo } from '@mana/shared-branding';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { trackGuestConversion } from '$lib/stores/funnel-tracking';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
const result = await authStore.signUp(email, password);
|
||||
if (result.success) {
|
||||
// Tracking must never block the success redirect.
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
trackGuestConversion();
|
||||
} catch {
|
||||
/* swallow tracking errors */
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Mana"
|
||||
logo={ManaLogo}
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
onSignUp={handleSignUp}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
{translations}
|
||||
>
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button, Input, Card } from '@mana/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let hasToken = $state(false);
|
||||
let token = $state<string | null>(null);
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Get token from URL query parameter
|
||||
token = $page.url.searchParams.get('token');
|
||||
hasToken = !!token;
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
||||
if (!token) {
|
||||
error = 'Reset token is missing';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
error = 'Password must be at least 12 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const result = await authStore.resetPasswordWithToken(token, password);
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error || 'Failed to reset password';
|
||||
} else {
|
||||
success = true;
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
goto('/login');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{#if success}
|
||||
Password reset successfully
|
||||
{:else if hasToken}
|
||||
Enter your new password
|
||||
{:else}
|
||||
Invalid or missing reset token
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if success}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">✅</div>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
Your password has been reset successfully. You will be redirected to the login page
|
||||
shortly.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Go to login
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if hasToken}
|
||||
<Card class="mt-8">
|
||||
<form onsubmit={handleSubmit}>
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 12 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
bind:value={confirmPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" {loading} class="w-full">
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
</p>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Request a new reset link
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,88 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { session } from '$lib/auth/session.svelte';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
let processing = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
const urlParams = $page.url.searchParams;
|
||||
const code = urlParams.get('code');
|
||||
const errorParam = urlParams.get('error');
|
||||
const errorDescription = urlParams.get('error_description');
|
||||
const type = urlParams.get('type'); // 'signup' or 'recovery' or 'invite' etc.
|
||||
const appName = urlParams.get('appName'); // App name for branding
|
||||
const next = urlParams.get('next');
|
||||
// Token via SSO-Cookie holen — der Browser sendet `mana_session`
|
||||
// automatisch mit, weil das Cookie auf `.mana.how` lebt.
|
||||
const ok = await session.tryRefresh();
|
||||
|
||||
// Handle OAuth errors
|
||||
if (errorParam) {
|
||||
error = errorDescription || errorParam;
|
||||
processing = false;
|
||||
setTimeout(() => {
|
||||
goto(`/login?error=${encodeURIComponent(error || 'Authentication failed')}`);
|
||||
}, 2000);
|
||||
if (!ok) {
|
||||
// Cookie weg oder Session expired → zurück aufs Portal mit
|
||||
// dem aktuellen Callback als Redirect-URL, damit der User
|
||||
// nach erneutem Login wieder hier landet.
|
||||
redirectToPortal({ next: page.url.searchParams.get('next') ?? '/' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine redirect destination
|
||||
let redirectUrl = next || '/';
|
||||
session.loadUserFromToken();
|
||||
|
||||
// For email verification/signup, redirect to welcome page
|
||||
// Vault-Unlock läuft automatisch im Root-Layout-$effect, sobald
|
||||
// `session.user.id` gesetzt ist (siehe routes/+layout.svelte).
|
||||
// Wir warten hier NICHT explizit — der User soll seine App so
|
||||
// schnell wie möglich sehen. Encrypted-Reads vor dem Unlock
|
||||
// rendern leere Liste; die liveQuery re-evaluiert sobald
|
||||
// `vaultClient.unlock()` durchgelaufen ist.
|
||||
|
||||
const next = page.url.searchParams.get('next');
|
||||
const type = page.url.searchParams.get('type');
|
||||
|
||||
// Email-Verification-Klick oder Signup-Welcome-Flow → Welcome-Page.
|
||||
if (type === 'signup' || type === 'email_verification') {
|
||||
redirectUrl = `/welcome${appName ? `?appName=${encodeURIComponent(appName)}` : ''}`;
|
||||
goto('/welcome');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle OAuth callback with code
|
||||
if (code) {
|
||||
try {
|
||||
// Exchange code for session
|
||||
// This is handled by Supabase automatically via the auth callback
|
||||
// Just wait a moment and redirect
|
||||
setTimeout(() => {
|
||||
goto(redirectUrl);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Auth callback error:', err);
|
||||
error = 'Failed to complete authentication';
|
||||
processing = false;
|
||||
setTimeout(() => {
|
||||
goto(`/login?error=${encodeURIComponent(error || '')}`);
|
||||
}, 2000);
|
||||
}
|
||||
} else if (type) {
|
||||
// Handle email confirmation callback (from Supabase email links)
|
||||
// The session should already be set by Supabase
|
||||
// Just redirect to the appropriate page
|
||||
setTimeout(() => {
|
||||
goto(redirectUrl);
|
||||
}, 1000);
|
||||
} else {
|
||||
// No code or type, redirect to login
|
||||
goto('/login');
|
||||
}
|
||||
// Sonst: Deep-Link wiederherstellen oder Default.
|
||||
goto(next && next.startsWith('/') ? next : '/');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Authenticating... - Mana</title>
|
||||
<title>Anmeldung wird abgeschlossen – Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-lg dark:bg-gray-800">
|
||||
{#if processing}
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<h2 class="mb-2 text-2xl font-bold">Authenticating...</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Please wait while we complete your sign-in.</p>
|
||||
{:else if error}
|
||||
{#if error}
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<h2 class="mb-2 text-2xl font-bold text-red-600 dark:text-red-400">Authentication Error</h2>
|
||||
<h2 class="mb-2 text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
Authentifizierungsfehler
|
||||
</h2>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{error}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Redirecting you back to login...</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Du wirst zurück zur Anmeldung geleitet…
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<h2 class="mb-2 text-2xl font-bold">Anmeldung wird abgeschlossen…</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Einen Moment bitte.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Redirect to the main reset-password page, preserving the token query parameter
|
||||
onMount(() => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
const redirectUrl = token ? `/reset-password?token=${token}` : '/reset-password';
|
||||
goto(redirectUrl, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Check } from '@mana/shared-icons';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</p>
|
||||
|
||||
<button
|
||||
onclick={() => goto('/login')}
|
||||
onclick={() => redirectToPortal({ next: '/' })}
|
||||
class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800"
|
||||
>
|
||||
Zur Anmeldung
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { HeartHalf } from '@mana/shared-icons';
|
||||
import { portalHref } from '$lib/auth/portal-redirect';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
{#if authStore.user}
|
||||
<a class="cta" href="/?app=feedback">In Mana öffnen</a>
|
||||
{:else}
|
||||
<a class="cta" href="/login">Login</a>
|
||||
<a class="cta" href={portalHref({ next: '/?app=feedback' })}>Login</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { X } from '@mana/shared-icons';
|
||||
import { redirectToPortal } from '$lib/auth/portal-redirect';
|
||||
|
||||
// Get error from URL query params
|
||||
const error = $derived($page.url.searchParams.get('error') || 'unknown_error');
|
||||
|
|
@ -44,14 +44,14 @@
|
|||
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
onclick={() => goto('/login')}
|
||||
onclick={() => redirectToPortal({ next: '/' })}
|
||||
class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800"
|
||||
>
|
||||
Zur Anmeldung
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => goto('/register')}
|
||||
onclick={() => redirectToPortal({ next: '/', target: 'register' })}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-neutral-600 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 dark:focus:ring-offset-neutral-800"
|
||||
>
|
||||
Neues Konto erstellen
|
||||
|
|
|
|||
|
|
@ -234,6 +234,12 @@ const APP_CONFIGS = [
|
|||
PUBLIC_SUPABASE_URL: (env) => env.MANA_SUPABASE_URL,
|
||||
PUBLIC_SUPABASE_ANON_KEY: (env) => env.MANA_SUPABASE_ANON_KEY,
|
||||
MIDDLEWARE_URL: (env) => env.MANA_AUTH_URL,
|
||||
// Auth-Portal: Login, Register, Reset, 2FA-Verify laufen
|
||||
// exklusiv über mana-auth-web. Lokal Port 3002, in Prod
|
||||
// auth.mana.how (gleicher Host wie mana-auth API — nginx
|
||||
// routet /api/* dorthin).
|
||||
PUBLIC_MANA_AUTH_URL: (env) => env.MANA_AUTH_URL,
|
||||
PUBLIC_AUTH_WEB_URL: (env) => env.MANA_AUTH_WEB_URL || 'http://localhost:3002',
|
||||
PUBLIC_UMAMI_WEBSITE_ID: (env) => env.UMAMI_WEBSITE_ID_MANA || '',
|
||||
PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '',
|
||||
// Speech-to-Text proxy (server-side only, never exposed to the client)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue