Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()

This commit is contained in:
Till-JS 2025-11-24 21:09:20 +01:00
parent 725db638ea
commit ef70a1af0b
198 changed files with 11113 additions and 3656 deletions

View file

@ -25,6 +25,18 @@
"vite": "^7.1.10"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-config": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-subscription-types": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/supabase-js": "^2.81.1"
}
}

View file

@ -0,0 +1,177 @@
/**
* Manadeck Web Auth Configuration
*
* This file initializes the shared auth package for the manadeck web app.
* It replaces the previous individual auth files:
* - services/authService.ts
* - services/tokenManager.ts
* - services/deviceManager.ts
* - utils/jwt.ts
*/
import { PUBLIC_API_URL } from '$env/static/public';
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
setupFetchInterceptor,
type StorageAdapter,
type DeviceManagerAdapter,
type NetworkAdapter,
type DeviceInfo,
} from '@manacore/shared-auth';
// Storage keys
const STORAGE_KEYS = {
APP_TOKEN: 'appToken',
REFRESH_TOKEN: 'refreshToken',
USER_EMAIL: 'userEmail',
DEVICE_ID: 'manadeck_device_id',
};
/**
* Session storage adapter for manadeck web
* Uses sessionStorage for tokens (clears on tab close)
* Uses localStorage for device ID (persists)
*/
const sessionStorageAdapter: StorageAdapter = {
async getItem<T = string>(key: string): Promise<T | null> {
if (typeof window === 'undefined') return null;
const value = sessionStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
if (typeof window === 'undefined') return;
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
if (typeof window === 'undefined') return;
sessionStorage.removeItem(key);
},
};
/**
* Device manager adapter for web
*/
const webDeviceAdapter: DeviceManagerAdapter = {
async getDeviceInfo(): Promise<DeviceInfo> {
if (typeof window === 'undefined') {
return {
deviceId: '',
deviceName: 'Server',
deviceType: 'web',
};
}
const deviceId = await webDeviceAdapter.getStoredDeviceId() || generateDeviceId();
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
if (userAgent.includes('Mac')) deviceName = 'Mac';
else if (userAgent.includes('Windows')) deviceName = 'Windows';
else if (userAgent.includes('Linux')) deviceName = 'Linux';
return {
deviceId,
deviceName,
deviceType: 'web',
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
},
};
/**
* Network adapter for web
*/
const webNetworkAdapter: NetworkAdapter = {
async isDeviceConnected(): Promise<boolean> {
if (typeof navigator === 'undefined') return true;
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
if (typeof navigator === 'undefined') return true;
return navigator.onLine;
},
};
/**
* Generate a unique device ID
*/
function generateDeviceId(): string {
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
// Initialize adapters
setStorageAdapter(sessionStorageAdapter);
setDeviceAdapter(webDeviceAdapter);
setNetworkAdapter(webNetworkAdapter);
// Create auth service instance
export const authService = createAuthService({
baseUrl: PUBLIC_API_URL,
storageKeys: {
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
},
endpoints: {
signIn: '/v1/auth/signin',
signUp: '/v1/auth/signup',
signOut: '/v1/auth/logout',
refresh: '/v1/auth/refresh',
validate: '/v1/auth/validate',
forgotPassword: '/v1/auth/forgot-password',
googleSignIn: '/v1/auth/google-signin',
appleSignIn: '/v1/auth/apple-signin',
credits: '/v1/auth/credits',
},
});
// Create token manager instance
export const tokenManager = createTokenManager(authService);
// Setup fetch interceptor (only in browser)
if (typeof window !== 'undefined') {
setupFetchInterceptor(authService, tokenManager, {
backendUrl: PUBLIC_API_URL,
});
}
// Re-export useful utilities from shared-auth
export {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
isB2BUser,
getB2BInfo,
TokenState,
} from '@manacore/shared-auth';
// Re-export types
export type {
UserData,
DecodedToken,
AuthResult,
CreditBalance,
B2BInfo,
} from '@manacore/shared-auth';

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Icon Component - Uses @manacore/shared-icons
* Phosphor Icons (Bold weight)
*/
import { iconPaths } from '@manacore/shared-icons';
interface Props {
name: keyof typeof iconPaths;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
{#if path}
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
>
{@html path}
</svg>
{:else}
<span class="text-red-500" title="Icon '{name}' not found"></span>
{/if}

View file

@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
size?: number;
color?: string;
}
let { size = 55, color = '#8b5cf6' }: Props = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- Cards/Deck icon -->
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 2v2" />
<path d="M18 2v2" />
<path d="M6 20v2" />
<path d="M18 20v2" />
<path d="M2 10h20" />
</svg>

View file

@ -1,156 +0,0 @@
import { PUBLIC_API_URL } from '$env/static/public';
import type {
SignInResponse,
SignUpResponse,
ManaUser,
CreditBalance
} from '$lib/types/auth';
import { getUserFromToken } from '$lib/utils/jwt';
import { tokenManager } from './tokenManager';
import { getDeviceInfo } from './deviceManager';
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<SignInResponse> {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password,
deviceId: deviceInfo.deviceId,
deviceName: deviceInfo.deviceName,
deviceType: deviceInfo.deviceType
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Sign in failed');
}
const data: SignInResponse = await response.json();
// Store tokens
tokenManager.setTokens(data.appToken, data.refreshToken);
// Extract user from token
data.user = getUserFromToken(data.appToken) || undefined;
return data;
},
/**
* Sign up with email and password
*/
async signUp(
email: string,
password: string,
username?: string
): Promise<SignUpResponse> {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password,
username,
deviceId: deviceInfo.deviceId,
deviceName: deviceInfo.deviceName,
deviceType: deviceInfo.deviceType
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Sign up failed');
}
const data: SignUpResponse = await response.json();
// Store tokens
tokenManager.setTokens(data.appToken, data.refreshToken);
// Extract user from token
data.user = getUserFromToken(data.appToken) || undefined;
return data;
},
/**
* Sign out
*/
async signOut(): Promise<void> {
const appToken = tokenManager.getAppToken();
if (appToken) {
try {
await fetch(`${PUBLIC_API_URL}/v1/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`
}
});
} catch (error) {
console.error('Logout request failed:', error);
}
}
// Clear tokens locally
tokenManager.clearTokens();
},
/**
* Get current user from token
*/
getCurrentUser(): ManaUser | null {
const appToken = tokenManager.getAppToken();
if (!appToken) return null;
return getUserFromToken(appToken);
},
/**
* Get user credit balance
*/
async getCreditBalance(): Promise<CreditBalance> {
const appToken = await tokenManager.getValidToken();
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/credits`, {
method: 'GET',
headers: {
Authorization: `Bearer ${appToken}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch credits');
}
return response.json();
},
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return !!tokenManager.getAppToken() && !tokenManager.isExpired();
},
/**
* Get app token
*/
getAppToken(): string | null {
return tokenManager.getAppToken();
}
};

View file

@ -1,45 +0,0 @@
import type { DeviceInfo } from '$lib/types/auth';
const DEVICE_ID_KEY = 'manadeck_device_id';
/**
* Generate a unique device ID for web
*/
function generateDeviceId(): string {
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Get or create device ID
*/
export function getDeviceId(): string {
if (typeof window === 'undefined') return '';
let deviceId = localStorage.getItem(DEVICE_ID_KEY);
if (!deviceId) {
deviceId = generateDeviceId();
localStorage.setItem(DEVICE_ID_KEY, deviceId);
}
return deviceId;
}
/**
* Get device info for authentication
*/
export function getDeviceInfo(): DeviceInfo {
const deviceId = getDeviceId();
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
// Simple device name based on user agent
let deviceName = 'Web Browser';
if (userAgent.includes('Mac')) deviceName = 'Mac';
else if (userAgent.includes('Windows')) deviceName = 'Windows';
else if (userAgent.includes('Linux')) deviceName = 'Linux';
return {
deviceId,
deviceName,
deviceType: 'web',
userAgent
};
}

View file

@ -1,169 +0,0 @@
import { PUBLIC_API_URL } from '$env/static/public';
import { decodeToken, isTokenExpired, getTokenExpiresIn } from '$lib/utils/jwt';
import { getDeviceInfo } from './deviceManager';
const TOKEN_REFRESH_BUFFER = 10; // seconds before expiry to trigger refresh
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAYS = [0, 1000, 2000, 5000]; // Progressive backoff
interface TokenState {
appToken: string | null;
refreshToken: string | null;
isRefreshing: boolean;
refreshPromise: Promise<string> | null;
}
class TokenManager {
private state: TokenState = {
appToken: null,
refreshToken: null,
isRefreshing: false,
refreshPromise: null
};
constructor() {
if (typeof window !== 'undefined') {
this.loadTokens();
}
}
private loadTokens() {
this.state.appToken = sessionStorage.getItem('appToken');
this.state.refreshToken = sessionStorage.getItem('refreshToken');
}
private saveTokens(appToken: string, refreshToken: string) {
this.state.appToken = appToken;
this.state.refreshToken = refreshToken;
sessionStorage.setItem('appToken', appToken);
sessionStorage.setItem('refreshToken', refreshToken);
}
clearTokens() {
this.state.appToken = null;
this.state.refreshToken = null;
sessionStorage.removeItem('appToken');
sessionStorage.removeItem('refreshToken');
sessionStorage.removeItem('deviceId');
}
getAppToken(): string | null {
return this.state.appToken;
}
getRefreshToken(): string | null {
return this.state.refreshToken;
}
setTokens(appToken: string, refreshToken: string) {
this.saveTokens(appToken, refreshToken);
}
/**
* Check if token needs refresh
*/
needsRefresh(): boolean {
if (!this.state.appToken) return false;
const expiresIn = getTokenExpiresIn(this.state.appToken);
return expiresIn > 0 && expiresIn <= TOKEN_REFRESH_BUFFER;
}
/**
* Check if token is expired
*/
isExpired(): boolean {
if (!this.state.appToken) return true;
return isTokenExpired(this.state.appToken);
}
/**
* Refresh token with retry logic
*/
async refreshAppToken(retryCount = 0): Promise<string> {
// If already refreshing, wait for that promise
if (this.state.isRefreshing && this.state.refreshPromise) {
return this.state.refreshPromise;
}
// Start new refresh
this.state.isRefreshing = true;
this.state.refreshPromise = this._performRefresh(retryCount);
try {
const newToken = await this.state.refreshPromise;
return newToken;
} finally {
this.state.isRefreshing = false;
this.state.refreshPromise = null;
}
}
private async _performRefresh(retryCount: number): Promise<string> {
if (!this.state.refreshToken) {
throw new Error('No refresh token available');
}
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: this.state.refreshToken,
deviceId: deviceInfo.deviceId
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Token refresh failed');
}
const data = await response.json();
const { appToken, refreshToken } = data;
// Save new tokens
this.saveTokens(appToken, refreshToken);
return appToken;
} catch (error) {
// Retry logic
if (retryCount < MAX_RETRY_ATTEMPTS) {
const delay = RETRY_DELAYS[retryCount];
await new Promise((resolve) => setTimeout(resolve, delay));
return this.refreshAppToken(retryCount + 1);
}
// Max retries reached, clear tokens
this.clearTokens();
throw error;
}
}
/**
* Get valid token (refreshes if needed)
*/
async getValidToken(): Promise<string> {
if (!this.state.appToken) {
throw new Error('No token available');
}
if (this.isExpired()) {
// Token is expired, try to refresh
return this.refreshAppToken();
}
if (this.needsRefresh()) {
// Token expires soon, refresh in background
this.refreshAppToken().catch(console.error);
}
return this.state.appToken;
}
}
// Export singleton
export const tokenManager = new TokenManager();

View file

@ -1,10 +1,22 @@
import type { ManaUser } from '$lib/types/auth';
import { authService } from '$lib/services/authService';
import { authService, type UserData } from '$lib/auth';
// Svelte 5 runes-based auth store
let user = $state<ManaUser | null>(null);
let loading = $state(true);
/**
* Convert UserData from shared-auth to ManaUser
*/
function toManaUser(userData: UserData | null): ManaUser | null {
if (!userData) return null;
return {
id: userData.id,
email: userData.email,
role: userData.role,
};
}
export const authStore = {
get user() {
return user;
@ -22,8 +34,10 @@ export const authStore = {
async initialize() {
loading = true;
try {
if (authService.isAuthenticated()) {
user = authService.getCurrentUser();
const isAuth = await authService.isAuthenticated();
if (isAuth) {
const userData = await authService.getUserFromToken();
user = toManaUser(userData);
}
} catch (error) {
console.error('Failed to initialize auth:', error);
@ -55,11 +69,43 @@ export const authStore = {
/**
* Check authentication status
*/
checkAuth() {
if (!authService.isAuthenticated()) {
async checkAuth() {
const isAuth = await authService.isAuthenticated();
if (!isAuth) {
user = null;
return false;
}
return true;
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const result = await authService.signIn(email, password);
if (result.success) {
const userData = await authService.getUserFromToken();
user = toManaUser(userData);
}
return result;
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const result = await authService.signUp(email, password);
if (result.success && !result.needsVerification) {
const userData = await authService.getUserFromToken();
user = toManaUser(userData);
}
return result;
},
/**
* Send password reset email
*/
async forgotPassword(email: string) {
return authService.forgotPassword(email);
}
};

View file

@ -1,6 +1,6 @@
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
import { getAuthenticatedSupabase } from '$lib/utils/supabase';
import { authService } from '$lib/services/authService';
import { authService } from '$lib/auth';
// Svelte 5 runes-based deck store
let decks = $state<Deck[]>([]);
@ -30,12 +30,12 @@ export const deckStore = {
error = null;
try {
const appToken = authService.getAppToken();
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const user = authService.getCurrentUser();
const user = await authService.getUserFromToken();
if (!user) {
throw new Error('No user found');
}
@ -71,7 +71,7 @@ export const deckStore = {
error = null;
try {
const appToken = authService.getAppToken();
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const supabase = await getAuthenticatedSupabase(appToken);
@ -104,10 +104,10 @@ export const deckStore = {
error = null;
try {
const appToken = authService.getAppToken();
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const user = authService.getCurrentUser();
const user = await authService.getUserFromToken();
if (!user) throw new Error('No user found');
const supabase = await getAuthenticatedSupabase(appToken);
@ -150,7 +150,7 @@ export const deckStore = {
error = null;
try {
const appToken = authService.getAppToken();
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const supabase = await getAuthenticatedSupabase(appToken);
@ -187,7 +187,7 @@ export const deckStore = {
error = null;
try {
const appToken = authService.getAppToken();
const appToken = await authService.getAppToken();
if (!appToken) throw new Error('Not authenticated');
const supabase = await getAuthenticatedSupabase(appToken);

View file

@ -1,63 +0,0 @@
import type { JwtPayload, ManaUser } from '$lib/types/auth';
/**
* Decode JWT token without verification (client-side only)
*/
export function decodeToken(token: string): JwtPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return decoded as JwtPayload;
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
/**
* Check if token is expired
*/
export function isTokenExpired(token: string): boolean {
const decoded = decodeToken(token);
if (!decoded || !decoded.exp) {
return true;
}
const now = Math.floor(Date.now() / 1000);
return decoded.exp < now;
}
/**
* Get time until token expires (in seconds)
*/
export function getTokenExpiresIn(token: string): number {
const decoded = decodeToken(token);
if (!decoded || !decoded.exp) {
return 0;
}
const now = Math.floor(Date.now() / 1000);
return Math.max(0, decoded.exp - now);
}
/**
* Extract user info from token
*/
export function getUserFromToken(token: string): ManaUser | null {
const decoded = decodeToken(token);
if (!decoded) {
return null;
}
return {
id: decoded.sub || decoded.user_id || '',
email: decoded.email || '',
role: decoded.role || 'user',
organizationId: decoded.app_settings?.b2b?.organizationId
};
}

View file

@ -9,9 +9,9 @@
async function loadCredits() {
loadingCredits = true;
try {
const { authService } = await import('$lib/services/authService');
const balance = await authService.getCreditBalance();
credits = balance.credits;
const { authService } = await import('$lib/auth');
const balance = await authService.getUserCredits();
credits = balance?.credits ?? null;
} catch (error) {
console.error('Failed to load credits:', error);
} finally {

View file

@ -1,83 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { authService } from '$lib/services/authService';
import { LoginPage } from '@manacore/shared-auth-ui';
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleSubmit() {
if (!email || !password) {
error = 'Please fill in all fields';
return;
}
loading = true;
error = '';
try {
await authService.signIn(email, password);
goto('/decks');
} catch (err: any) {
error = err.message || 'Sign in failed';
} finally {
loading = false;
}
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<svelte:head>
<title>Sign In - Manadeck</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
<p class="text-muted-foreground">Sign in to your Manadeck account</p>
</div>
<Card>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<Input
type="email"
label="Email"
bind:value={email}
placeholder="you@example.com"
autocomplete="email"
required
/>
<Input
type="password"
label="Password"
bind:value={password}
placeholder="••••••••"
autocomplete="current-password"
required
/>
{#if error}
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
{/if}
<Button type="submit" fullWidth {loading}>
Sign in
</Button>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-muted-foreground">Don't have an account?</span>
<a href="/register" class="ml-2 text-primary hover:underline">
Sign up
</a>
</div>
</Card>
</div>
</div>
<LoginPage
appName="ManaDeck"
logo={ManaDeckLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={false}
enableApple={false}
successRedirect="/decks"
registerPath="/register"
lightBackground="#faf5ff"
darkBackground="#1a1625"
/>

View file

@ -1,112 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { authService } from '$lib/services/authService';
import { RegisterPage } from '@manacore/shared-auth-ui';
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
let email = $state('');
let username = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let loading = $state(false);
async function handleSubmit() {
if (!email || !password || !confirmPassword) {
error = 'Please fill in all fields';
return;
}
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
loading = true;
error = '';
try {
await authService.signUp(email, password, username || undefined);
goto('/decks');
} catch (err: any) {
error = err.message || 'Sign up failed';
} finally {
loading = false;
}
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>Sign Up - Manadeck</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold mb-2">Create your account</h1>
<p class="text-muted-foreground">Start building your knowledge decks</p>
</div>
<Card>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<Input
type="email"
label="Email"
bind:value={email}
placeholder="you@example.com"
autocomplete="email"
required
/>
<Input
type="text"
label="Username (optional)"
bind:value={username}
placeholder="Choose a username"
autocomplete="username"
/>
<Input
type="password"
label="Password"
bind:value={password}
placeholder="••••••••"
autocomplete="new-password"
required
/>
<Input
type="password"
label="Confirm Password"
bind:value={confirmPassword}
placeholder="••••••••"
autocomplete="new-password"
required
/>
{#if error}
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
{/if}
<Button type="submit" fullWidth {loading}>
Create account
</Button>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-muted-foreground">Already have an account?</span>
<a href="/login" class="ml-2 text-primary hover:underline">
Sign in
</a>
</div>
</Card>
</div>
</div>
<RegisterPage
appName="ManaDeck"
logo={ManaDeckLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
goto={goto}
successRedirect="/decks"
loginPath="/login"
lightBackground="#faf5ff"
darkBackground="#1a1625"
/>

View file

@ -1,10 +1,19 @@
import { themeColors } from '@manacore/shared-tailwind/colors';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Shared theme colors
...themeColors,
// ManaDeck specific HSL-based colors
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
surface: 'hsl(var(--surface))',