mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 07:41:09 +02:00
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:
parent
5e15f57816
commit
79acf8b8b8
12 changed files with 537 additions and 503 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
32
apps/presi/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/presi/apps/web/src/lib/components/AppSlider.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
4
apps/presi/apps/web/src/lib/stores/navigation.ts
Normal file
4
apps/presi/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue