feat(wisekeep): add auth routes and protected layout

- Add auth store using shared-auth
- Add (auth) route group with login, register, forgot-password pages
- Move main pages to (protected) route group
- Add AppSlider and Header components
- Update layout for authenticated routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-28 21:03:29 +01:00
parent bc21a25295
commit 6b8ab585d9
13 changed files with 554 additions and 51 deletions

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,204 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Using Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, error: null, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
if (!authService) {
return null;
}
try {
const credits = await authService.getUserCredits();
return credits;
} catch (error) {
console.error('Failed to get credits:', error);
return null;
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
title: 'Passwort vergessen',
subtitle: 'Gib deine E-Mail ein, um einen Reset-Link zu erhalten',
emailPlaceholder: 'E-Mail',
resetButton: 'Reset-Link senden',
sending: 'Wird gesendet...',
success: 'E-Mail gesendet!',
backToLogin: 'Zurück zur Anmeldung',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
resetFailed: 'Zurücksetzen fehlgeschlagen',
resetSuccess: 'Bitte überprüfe deine E-Mails',
};
async function handleResetPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>Passwort vergessen | Wisekeep</title>
</svelte:head>
<ForgotPasswordPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onResetPassword={handleResetPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// German translations
const translations = {
title: 'Anmelden',
subtitle: 'Melde dich mit deinem Konto an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolgreich!',
orDivider: 'oder',
noAccount: 'Noch kein Konto?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...',
};
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>Anmelden | Wisekeep</title>
</svelte:head>
<LoginPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
title: 'Registrieren',
subtitle: 'Erstelle dein Konto',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
confirmPasswordPlaceholder: 'Passwort wiederholen',
signUpButton: 'Registrieren',
signingUp: 'Wird registriert...',
success: 'Erfolgreich!',
orDivider: 'oder',
hasAccount: 'Bereits ein Konto?',
signIn: 'Jetzt anmelden',
skipToForm: 'Zum Registrierungsformular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
passwordMinLength: 'Passwort muss mindestens 8 Zeichen haben',
passwordsNotMatch: 'Passwörter stimmen nicht überein',
signUpFailed: 'Registrierung fehlgeschlagen',
signUpSuccess: 'Erfolgreich registriert. Weiterleitung...',
verificationRequired: 'Bitte überprüfe deine E-Mails zur Bestätigung',
};
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>Registrieren | Wisekeep</title>
</svelte:head>
<RegisterPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,13 @@
/**
* Protected routes layout server
* Auth checking is done client-side via Mana Core Auth
*/
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ url }) => {
// Return the current path for client-side redirect logic
return {
pathname: url.pathname,
};
};

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
import type { LayoutData } from './$types';
let { children, data }: { children: any; data: LayoutData } = $props();
let isChecking = $state(true);
// Check auth on mount and redirect if not authenticated
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const redirectTo = encodeURIComponent(data.pathname || '/');
goto(`/login?redirectTo=${redirectTo}`);
return;
}
// Initialize WebSocket after auth check
initWebSocket();
isChecking = false;
});
onDestroy(() => {
cleanup();
});
async function handleSignOut() {
await authStore.signOut();
goto('/login');
}
</script>
{#if isChecking}
<!-- Loading state while checking auth -->
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
{:else}
<div class="min-h-screen flex flex-col">
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-purple-600">Wisekeep</a>
<nav class="flex items-center gap-6">
<a
href="/"
class="transition-colors {$page.url.pathname === '/'
? 'text-purple-600 font-medium'
: 'text-gray-600 hover:text-gray-900'}"
>
Dashboard
</a>
<a
href="/transcribe"
class="transition-colors {$page.url.pathname === '/transcribe'
? 'text-purple-600 font-medium'
: 'text-gray-600 hover:text-gray-900'}"
>
Transcribe
</a>
<a
href="/transcripts"
class="transition-colors {$page.url.pathname === '/transcripts'
? 'text-purple-600 font-medium'
: 'text-gray-600 hover:text-gray-900'}"
>
Transcripts
</a>
<a
href="/playlists"
class="transition-colors {$page.url.pathname === '/playlists'
? 'text-purple-600 font-medium'
: 'text-gray-600 hover:text-gray-900'}"
>
Playlists
</a>
</nav>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {$isConnected ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm text-gray-500">
{$isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{#if authStore.user}
<span class="text-sm text-gray-600 hidden sm:block">
{authStore.user.email}
</span>
{/if}
<button
onclick={handleSignOut}
class="px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Abmelden
</button>
</div>
</div>
</header>
<main class="flex-1">
{@render children()}
</main>
<footer class="bg-gray-100 border-t py-4">
<div class="max-w-7xl mx-auto px-4 text-center text-sm text-gray-500">
Wisekeep - AI-powered wisdom extraction from video
</div>
</footer>
</div>
{/if}

View file

@ -24,7 +24,7 @@
</script>
<svelte:head>
<title>Transcriber - Dashboard</title>
<title>Dashboard | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
@ -38,7 +38,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Total Transcripts</div>
<div class="text-3xl font-bold text-primary-600">{stats.totalTranscripts}</div>
<div class="text-3xl font-bold text-purple-600">{stats.totalTranscripts}</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Storage Used</div>
@ -59,7 +59,7 @@
<h2 class="text-xl font-semibold mb-4">Quick Start</h2>
<a
href="/transcribe"
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
class="inline-flex items-center px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Start New Transcription
</a>
@ -87,7 +87,7 @@
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-primary-600 h-2 rounded-full transition-all"
class="bg-purple-600 h-2 rounded-full transition-all"
style="width: {job.progress}%"
></div>
</div>

View file

@ -29,7 +29,7 @@
</script>
<svelte:head>
<title>Playlists - Transcriber</title>
<title>Playlists | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">

View file

@ -53,7 +53,7 @@
</script>
<svelte:head>
<title>New Transcription - Transcriber</title>
<title>New Transcription | Wisekeep</title>
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-8">
@ -72,7 +72,7 @@
bind:value={url}
placeholder="https://www.youtube.com/watch?v=..."
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
@ -81,7 +81,7 @@
<select
id="language"
bind:value={language}
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{#each languages as lang}
<option value={lang.code}>{lang.name}</option>
@ -116,7 +116,7 @@
<select
id="model"
bind:value={model}
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{#each models as m}
<option value={m.value}>{m.label}</option>
@ -128,7 +128,7 @@
<button
type="submit"
disabled={loading || !url}
class="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Starting...' : 'Start Transcription'}
</button>

View file

@ -18,7 +18,7 @@
</script>
<svelte:head>
<title>Transcripts - Transcriber</title>
<title>Transcripts | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
@ -31,7 +31,7 @@
<p class="text-gray-500 mb-4">No transcripts yet</p>
<a
href="/transcribe"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
class="inline-flex items-center px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Create your first transcript
</a>
@ -54,7 +54,7 @@
</div>
{#if job.transcriptText}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-700">
<summary class="cursor-pointer text-sm text-purple-600 hover:text-purple-700">
View transcript
</summary>
<pre

View file

@ -1,43 +1,7 @@
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
onMount(() => {
initWebSocket();
});
onDestroy(() => {
cleanup();
});
let { children } = $props();
</script>
<div class="min-h-screen flex flex-col">
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-primary-600"> Transcriber </a>
<nav class="flex items-center gap-6">
<a href="/" class="text-gray-600 hover:text-gray-900">Dashboard</a>
<a href="/transcribe" class="text-gray-600 hover:text-gray-900">Transcribe</a>
<a href="/transcripts" class="text-gray-600 hover:text-gray-900">Transcripts</a>
<a href="/playlists" class="text-gray-600 hover:text-gray-900">Playlists</a>
</nav>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {$isConnected ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm text-gray-500">
{$isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</header>
<main class="flex-1">
<slot />
</main>
<footer class="bg-gray-100 border-t py-4">
<div class="max-w-7xl mx-auto px-4 text-center text-sm text-gray-500">
YouTube Transcriber - AI-powered video transcription
</div>
</footer>
</div>
{@render children()}