feat(presi): add PillNavigation and fix auth service JWT parsing

Presi webapp:
- Add PillNavigation from @manacore/shared-ui
- Create navigation store for sidebar/collapsed state
- Update layout with floating/sidebar nav modes
- Hide nav on presentation and shared routes
- Add theme toggle and logout to navigation

Auth service:
- Fix JWT private key parsing by converting \n to actual newlines
- Required for Docker env vars where newlines are escaped

Environment:
- Add localhost:5174-5179 to CORS_ORIGINS for all webapp ports

🤖 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 20:49:45 +01:00
parent 5e15f57816
commit 79acf8b8b8
12 changed files with 537 additions and 503 deletions

View file

@ -38,7 +38,7 @@ JWT_ACCESS_TOKEN_EXPIRY=15m
JWT_REFRESH_TOKEN_EXPIRY=7d
JWT_ISSUER=manacore
JWT_AUDIENCE=manacore
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5177,http://localhost:8081
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:8081
CREDITS_SIGNUP_BONUS=150
CREDITS_DAILY_FREE=5
RATE_LIMIT_TTL=60

View file

@ -30,6 +30,10 @@
},
"dependencies": {
"@presi/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"lucide-svelte": "^0.460.0"
},
"type": "module"

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 (English)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.en,
longDescription: app.longDescription.en,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.en;
const labels = APP_SLIDER_LABELS.en;
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

@ -1,75 +1,190 @@
import { browser } from '$app/environment';
import { authApi } from '$lib/api/client';
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
interface User {
id: string;
email: string;
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;
}
function createAuthStore() {
let isAuthenticated = $state(false);
let user = $state<User | null>(null);
let isLoading = $state(true);
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
function init() {
if (!browser) {
isLoading = false;
export const auth = {
// Getters
get user() {
return user;
},
get isLoading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async init() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
const token = localStorage.getItem('accessToken');
if (token) {
// Decode JWT to get user info
try {
const payload = JSON.parse(atob(token.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
} catch (e) {
console.error('Failed to decode token:', e);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
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;
}
isLoading = false;
}
},
async function login(email: string, password: string) {
const data = await authApi.login(email, password);
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
return data;
}
/**
* Sign in with email and password
* Returns AuthResult compatible format for shared-auth-ui
*/
async login(email: string, password: string): Promise<{ success: boolean; error?: string }> {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
async function register(email: string, password: string) {
const data = await authApi.register(email, password);
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
return data;
}
try {
const result = await authService.signIn(email, password);
function logout() {
authApi.logout();
user = null;
isAuthenticated = false;
}
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
return {
get isAuthenticated() {
return isAuthenticated;
},
get user() {
return user;
},
get isLoading() {
return isLoading;
},
init,
login,
register,
logout,
};
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
export const auth = createAuthStore();
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async register(
email: string,
password: string
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> {
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, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.login(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 logout() {
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 forgotPassword(email: string): Promise<{ success: boolean; error?: 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 };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -1,22 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, LogOut, Settings, User, Sun, Moon } from 'lucide-svelte';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import '../app.css';
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let isDark = $state(false);
onMount(() => {
auth.init();
isDark =
localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
});
// Navigation items for Presi
const navItems: PillNavItem[] = [
{ href: '/', label: 'Decks', icon: 'document' },
{ href: '/profile', label: 'Profil', icon: 'user' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
function toggleTheme() {
// Public routes that don't require auth
const publicRoutes = ['/login', '/register', '/forgot-password'];
// Routes where nav should be hidden
const hideNavRoutes = ['/present/', '/shared/'];
function shouldHideNav(pathname: string): boolean {
return hideNavRoutes.some((route) => pathname.startsWith(route));
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('presi-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('presi-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
isDark = !isDark;
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', isDark);
@ -27,86 +62,136 @@
goto('/login');
}
// Public routes that don't require auth
const publicRoutes = ['/login', '/register', '/forgot-password'];
// Redirect to login if not authenticated
$effect(() => {
if (!auth.isLoading && !auth.isAuthenticated && !publicRoutes.includes($page.url.pathname)) {
goto('/login');
}
});
onMount(() => {
// Initialize auth
auth.init();
// Initialize theme
isDark =
localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('presi-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('presi-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
});
</script>
<svelte:head>
<title>Presi - Presentation Creator</title>
</svelte:head>
{#if auth.isLoading}
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
></div>
{#if loading || auth.isLoading}
<div class="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary-500 border-r-transparent"
></div>
<p class="text-slate-500 dark:text-slate-400">Laden...</p>
</div>
</div>
{:else if auth.isAuthenticated || publicRoutes.includes($page.url.pathname)}
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
{#if auth.isAuthenticated && !$page.url.pathname.startsWith('/present/')}
<header
class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40"
{#if auth.isAuthenticated && !publicRoutes.includes($page.url.pathname) && !shouldHideNav($page.url.pathname)}
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Presi"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showLanguageSwitcher={false}
showLogout={true}
onLogout={handleLogout}
primaryColor="#0ea5e9"
/>
<!-- Main Content with dynamic padding based on nav mode -->
<main
class="main-content"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<a
href="/"
class="flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-white"
>
<Presentation class="w-6 h-6 text-primary-500" />
Presi
</a>
<div class="flex items-center gap-2">
<button
onclick={toggleTheme}
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label="Toggle theme"
>
{#if isDark}
<Sun class="w-5 h-5 text-slate-600 dark:text-slate-300" />
{:else}
<Moon class="w-5 h-5 text-slate-600 dark:text-slate-300" />
{/if}
</button>
<a
href="/settings"
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<Settings class="w-5 h-5 text-slate-600 dark:text-slate-300" />
</a>
<a
href="/profile"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<User class="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span class="text-sm text-slate-700 dark:text-slate-200">{auth.user?.email}</span>
</a>
<button
onclick={handleLogout}
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors group"
aria-label="Logout"
>
<LogOut
class="w-5 h-5 text-slate-600 dark:text-slate-300 group-hover:text-red-600"
/>
</button>
</div>
</div>
<div class="content-wrapper">
{@render children()}
</div>
</header>
{/if}
<main>
<slot />
</main>
</div>
{:else}
<!-- Public/Presentation routes without nav -->
<main class="min-h-screen bg-slate-50 dark:bg-slate-900">
{@render children()}
</main>
</div>
{/if}
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 100px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>

View file

@ -1,134 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Presentation, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
let email = $state('');
let error = $state('');
let isLoading = $state(false);
let resetSent = $state(false);
// English translations
const translations = {
titleForm: 'Reset Password',
titleSuccess: 'Email Sent',
description: "Enter your email address and we'll send you a link to reset your password.",
emailPlaceholder: 'Email',
sendResetLinkButton: 'Send Reset Link',
sending: 'Sending...',
backToLogin: 'Back to Login',
resendEmail: 'Resend Email',
successMessage: "We've sent a password reset link to {email}. Please check your inbox.",
emailRequired: 'Email is required',
sendFailed: 'Failed to send reset email',
};
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (!email.trim()) {
error = 'Please enter your email address';
return;
}
isLoading = true;
try {
const response = await fetch(`${AUTH_URL}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to send reset email');
}
resetSent = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to send reset email';
} finally {
isLoading = false;
}
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<svelte:head>
<title>Forgot Password - Presi</title>
<title>Forgot Password | Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
{resetSent ? 'Check your email' : 'Reset password'}
</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">
{resetSent
? `We've sent reset instructions to ${email}`
: 'Enter your email to receive reset instructions'}
</p>
</div>
{#if resetSent}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center">
<div class="flex justify-center mb-4">
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
<CheckCircle class="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
<p class="text-slate-600 dark:text-slate-400 mb-6">
If an account exists with this email, you'll receive password reset instructions shortly.
</p>
<a
href="/login"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<ArrowLeft class="w-4 h-4" />
Back to login
</a>
</div>
{:else}
<form
onsubmit={handleSubmit}
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
>
{#if error}
<div
class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
>
{error}
</div>
{/if}
<div>
<label
for="email"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Sending...' : 'Send reset instructions'}
</button>
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium"
>Back to login</a
>
</p>
</form>
{/if}
</div>
</div>
<ForgotPasswordPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -1,119 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
isLoading = true;
// English translations
const translations = {
title: 'Sign In',
subtitle: 'Sign in with your account',
emailPlaceholder: 'Email',
passwordPlaceholder: 'Password',
rememberMe: 'Remember me',
forgotPassword: 'Forgot password?',
signInButton: 'Sign In',
signingIn: 'Signing in...',
success: 'Success!',
orDivider: 'or',
noAccount: "Don't have an account?",
createAccount: 'Create one',
skipToForm: 'Skip to login form',
showPassword: 'Show password',
hidePassword: 'Hide password',
emailRequired: 'Email is required',
emailInvalid: 'Please enter a valid email address',
passwordRequired: 'Password is required',
signInFailed: 'Sign in failed',
googleSignInFailed: 'Google sign in failed',
signInSuccess: 'Successfully signed in. Redirecting...',
googleSignInSuccess: 'Successfully signed in with Google. Redirecting...',
};
try {
await auth.login(email, password);
goto('/');
} catch (e) {
error = e instanceof Error ? e.message : 'Login failed';
} finally {
isLoading = false;
}
async function handleSignIn(email: string, password: string) {
return auth.login(email, password);
}
</script>
<svelte:head>
<title>Login - Presi</title>
<title>Login | Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Welcome back</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Sign in to your Presi account</p>
</div>
<form
onsubmit={handleSubmit}
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
>
{#if error}
<div
class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
>
<AlertCircle class="w-4 h-4 flex-shrink-0" />
{error}
</div>
{/if}
<div>
<label
for="email"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="password"
bind:value={password}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
<div class="flex items-center justify-between text-sm">
<a
href="/forgot-password"
class="text-slate-600 dark:text-slate-400 hover:text-primary-600"
>
Forgot password?
</a>
<p class="text-slate-600 dark:text-slate-400">
No account?
<a href="/register" class="text-primary-600 hover:text-primary-700 font-medium">Sign up</a
>
</p>
</div>
</form>
</div>
</div>
<LoginPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -1,142 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let isLoading = $state(false);
// English translations
const translations = {
title: 'Create Account',
emailPlaceholder: 'Email',
passwordPlaceholder: 'Password',
confirmPasswordPlaceholder: 'Confirm Password',
passwordRequirements:
'Password must be at least 8 characters with lowercase, uppercase, number, and special character.',
createAccountButton: 'Create Account',
creatingAccount: 'Creating Account...',
backToLogin: 'Back to Login',
showPassword: 'Show password',
hidePassword: 'Hide password',
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
confirmPasswordRequired: 'Please confirm your password',
passwordsDoNotMatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 8 characters',
passwordStrengthError:
'Password must include lowercase, uppercase, number, and special character',
registrationFailed: 'Registration failed',
accountCreated: 'Account created! Please check your email to verify your account.',
};
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
isLoading = true;
try {
await auth.register(email, password);
goto('/');
} catch (e) {
error = e instanceof Error ? e.message : 'Registration failed';
} finally {
isLoading = false;
}
async function handleSignUp(email: string, password: string) {
return auth.register(email, password);
}
</script>
<svelte:head>
<title>Register - Presi</title>
<title>Register | Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Create account</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Start creating amazing presentations</p>
</div>
<form
onsubmit={handleSubmit}
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
>
{#if error}
<div
class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
>
<AlertCircle class="w-4 h-4 flex-shrink-0" />
{error}
</div>
{/if}
<div>
<label
for="email"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="password"
bind:value={password}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
Confirm Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
Already have an account?
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Sign in</a>
</p>
</form>
</div>
</div>
<RegisterPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -1,7 +1,10 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
],
darkMode: 'class',
theme: {
extend: {

View file

@ -1,9 +1,27 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5178,
fs: {
allow: [
// Allow serving files from the monorepo root node_modules
path.resolve(__dirname, '../../../../node_modules'),
// Default allowed paths
path.resolve(__dirname, 'src'),
path.resolve(__dirname, '.svelte-kit'),
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, '../../node_modules'),
],
},
},
ssr: {
noExternal: ['@manacore/shared-ui'],
},
optimizeDeps: {
exclude: ['@manacore/shared-ui'],
},
});

View file

@ -209,7 +209,8 @@ export class AuthService {
if (!privateKeyRaw) {
throw new Error('JWT private key not configured');
}
const privateKey: string = privateKeyRaw;
// Convert escaped newlines to actual newlines (for Docker env vars)
const privateKey: string = privateKeyRaw.replace(/\\n/g, '\n');
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
const issuer = this.configService.get<string>('jwt.issuer');