diff --git a/apps/manacore/apps/web/package.json b/apps/manacore/apps/web/package.json index e972ba6a2..b9427f342 100644 --- a/apps/manacore/apps/web/package.json +++ b/apps/manacore/apps/web/package.json @@ -50,7 +50,6 @@ "@manacore/local-store": "workspace:*", "@manacore/qr-export": "workspace:*", "@manacore/shared-auth": "workspace:*", - "@manacore/shared-auth-stores": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-config": "workspace:*", diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 5df932791..40fcdadea 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -2,6 +2,6 @@ * Auth Store — uses centralized Mana auth factory. */ -import { createManaAuthStore } from '@manacore/shared-auth-stores'; +import { createManaAuthStore } from '@manacore/shared-auth-ui'; export const authStore = createManaAuthStore(); diff --git a/games/arcade/apps/web/package.json b/games/arcade/apps/web/package.json index 2a6d5f165..ac10e405e 100644 --- a/games/arcade/apps/web/package.json +++ b/games/arcade/apps/web/package.json @@ -35,7 +35,6 @@ "@manacore/local-store": "workspace:*", "@manacore/shared-app-onboarding": "workspace:*", "@manacore/shared-auth": "workspace:*", - "@manacore/shared-auth-stores": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-error-tracking": "workspace:*", diff --git a/games/arcade/apps/web/src/lib/stores/auth.svelte.ts b/games/arcade/apps/web/src/lib/stores/auth.svelte.ts index 060eba973..49bda09b5 100644 --- a/games/arcade/apps/web/src/lib/stores/auth.svelte.ts +++ b/games/arcade/apps/web/src/lib/stores/auth.svelte.ts @@ -1,4 +1,4 @@ -import { createManaAuthStore } from '@manacore/shared-auth-stores'; +import { createManaAuthStore } from '@manacore/shared-auth-ui'; export const authStore = createManaAuthStore({ devBackendPort: 3011, diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index cc159c6e0..dfba7fa5a 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -33,6 +33,18 @@ export { } from './utils/guestNudge'; export { parseUserAgent, getDeviceType, formatUserAgent } from './utils/userAgent'; +// Auth Stores (absorbed from @manacore/shared-auth-stores) +export { createManaAuthStore } from './stores/createManaAuthStore.svelte'; +export type { ManaAuthStoreConfig, ManaAuthStore } from './stores/createManaAuthStore.svelte'; +export { createAuthStore } from './stores/createAuthStore.svelte'; +export type { + BaseUser, + AuthServiceAdapter, + AuthStoreState, + AuthStoreActions, + AuthStore, +} from './stores/store-types'; + // Types export type { AuthUIConfig, diff --git a/packages/shared-auth-ui/src/stores/createAuthStore.svelte.ts b/packages/shared-auth-ui/src/stores/createAuthStore.svelte.ts new file mode 100644 index 000000000..9d207598d --- /dev/null +++ b/packages/shared-auth-ui/src/stores/createAuthStore.svelte.ts @@ -0,0 +1,186 @@ +/** + * Svelte 5 Auth Store Factory + * + * Creates a reactive auth store using Svelte 5 runes. + * Generic over user type to support app-specific user models. + * + * @example + * ```ts + * // In your app's auth store file + * import { createAuthStore } from '@manacore/shared-auth-stores'; + * import { authService } from '$lib/auth'; + * import type { AppUser } from '$lib/types'; + * + * export const authStore = createAuthStore(authService); + * ``` + */ + +import type { AuthServiceAdapter, AuthResult, BaseUser } from './types'; + +/** + * Create a Svelte 5 runes-based auth store + * + * @param authService - Auth service adapter implementing the AuthServiceAdapter interface + * @returns Reactive auth store with state and actions + */ +export function createAuthStore(authService: AuthServiceAdapter) { + // Reactive state using Svelte 5 runes + let user = $state(null); + let loading = $state(true); + let error = $state(null); + + return { + // Reactive getters + get user() { + return user; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get isAuthenticated() { + return !!user; + }, + + /** + * Initialize auth state from stored tokens/session + */ + async initialize() { + loading = true; + error = null; + try { + const isAuth = await authService.isAuthenticated(); + if (isAuth) { + user = await authService.getUserFromToken(); + } else { + user = null; + } + } catch (e) { + console.error('Failed to initialize auth:', e); + error = e instanceof Error ? e.message : 'Failed to initialize authentication'; + user = null; + } finally { + loading = false; + } + }, + + /** + * Set user manually (useful for SSR hydration) + */ + setUser(newUser: TUser | null) { + user = newUser; + error = null; + }, + + /** + * Sign in with email and password + */ + async signIn(email: string, password: string): Promise> { + loading = true; + error = null; + try { + const result = await authService.signIn(email, password); + if (result.success) { + user = await authService.getUserFromToken(); + } else { + error = result.error || 'Sign in failed'; + } + return result; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Sign in failed'; + error = errorMessage; + return { success: false, error: errorMessage }; + } finally { + loading = false; + } + }, + + /** + * Sign up with email and password + */ + async signUp(email: string, password: string): Promise> { + loading = true; + error = null; + try { + const result = await authService.signUp(email, password); + if (result.success && !result.needsVerification) { + user = await authService.getUserFromToken(); + } else if (!result.success) { + error = result.error || 'Sign up failed'; + } + return result; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Sign up failed'; + error = errorMessage; + return { success: false, error: errorMessage }; + } finally { + loading = false; + } + }, + + /** + * Send password reset email + */ + async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> { + loading = true; + error = null; + try { + const result = await authService.forgotPassword(email); + if (!result.success) { + error = result.error || 'Password reset failed'; + } + return result; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Password reset failed'; + error = errorMessage; + return { success: false, error: errorMessage }; + } finally { + loading = false; + } + }, + + /** + * Sign out user + */ + async signOut() { + loading = true; + error = null; + try { + await authService.signOut(); + user = null; + } catch (e) { + console.error('Sign out failed:', e); + error = e instanceof Error ? e.message : 'Sign out failed'; + } finally { + loading = false; + } + }, + + /** + * Check authentication status + */ + async checkAuth(): Promise { + try { + const isAuth = await authService.isAuthenticated(); + if (!isAuth) { + user = null; + return false; + } + return true; + } catch (e) { + console.error('Auth check failed:', e); + user = null; + return false; + } + }, + + /** + * Clear error state + */ + clearError() { + error = null; + }, + }; +} diff --git a/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts b/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts new file mode 100644 index 000000000..d7fc937dd --- /dev/null +++ b/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts @@ -0,0 +1,320 @@ +/** + * Mana Auth Store Factory + * + * Creates a complete auth store using @manacore/shared-auth. + * Replaces the ~350 lines of duplicated auth.svelte.ts in each app + * with a single factory call. + * + * @example + * ```ts + * // apps/todo/apps/web/src/lib/stores/auth.svelte.ts + * import { createManaAuthStore } from '@manacore/shared-auth-stores'; + * + * export const authStore = createManaAuthStore({ + * devBackendPort: 3031, + * }); + * ``` + * + * @example With post-login callback + * ```ts + * import { createManaAuthStore } from '@manacore/shared-auth-stores'; + * import { apiClient } from '$lib/api/client'; + * + * export const authStore = createManaAuthStore({ + * devBackendPort: 3030, + * onAuthenticated: async (authService) => { + * const token = await authService.getAppToken(); + * if (token) apiClient.setAccessToken(token); + * }, + * }); + * ``` + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth, type UserData, type AuthServiceInterface } from '@manacore/shared-auth'; + +export interface ManaAuthStoreConfig { + /** Dev backend port (e.g. 3031 for todo). Only used in development. */ + devBackendPort?: number; + /** Dev auth port. Defaults to 3001. */ + devAuthPort?: number; + /** Callback after successful authentication (sign in, SSO, 2FA). */ + onAuthenticated?: (authService: AuthServiceInterface) => void | Promise; + /** Callback after sign out. */ + onSignOut?: () => void | Promise; +} + +export function createManaAuthStore(config: ManaAuthStoreConfig = {}) { + const devAuthUrl = `http://localhost:${config.devAuthPort ?? 3001}`; + const devBackendUrl = config.devBackendPort ? `http://localhost:${config.devBackendPort}` : ''; + + // URL resolution (runtime, not build-time) + function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + if (injected) return injected; + return import.meta.env.DEV ? devAuthUrl : ''; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || devAuthUrl; + } + + function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (injected) return injected; + return import.meta.env.DEV ? devBackendUrl : ''; + } + return process.env.PUBLIC_BACKEND_URL || devBackendUrl; + } + + // Lazy init (SSR-safe) + let _authService: AuthServiceInterface | null = null; + let _tokenManager: ReturnType['tokenManager'] | null = null; + + function getAuthService() { + if (!browser) return null; + if (!_authService) { + const backendUrl = getBackendUrl(); + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + ...(backendUrl ? { backendUrl } : {}), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; + } + + function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; + } + + async function handleAuthenticated() { + if (config.onAuthenticated) { + const svc = getAuthService(); + if (svc) await config.onAuthenticated(svc); + } + } + + // Reactive state + let user = $state(null); + let loading = $state(true); + let initialized = $state(false); + + return { + // Getters + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + let authenticated = await authService.isAuthenticated(); + if (!authenticated) { + const ssoResult = await authService.trySSO(); + if (ssoResult.success) authenticated = true; + } + if (authenticated) { + user = await authService.getUserFromToken(); + await handleAuthenticated(); + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + // 2FA + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + user = await authService.getUserFromToken(); + await handleAuthenticated(); + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + user = await authService.getUserFromToken(); + await handleAuthenticated(); + } + return result; + }, + + // Magic Link + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + + // Passkeys + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + try { + const result = await authService.signInWithPasskey(); + if (!result.success) + return { success: false, error: result.error || 'Passkey authentication failed' }; + user = await authService.getUserFromToken(); + await handleAuthenticated(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + + // Sign In + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + try { + const result = await authService.signIn(email, password); + if (!result.success) return { success: false, error: result.error || 'Login failed' }; + user = await authService.getUserFromToken(); + await handleAuthenticated(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + + // Sign Up + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) + return { success: false, error: 'Auth not available on server', needsVerification: false }; + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, sourceAppUrl); + if (!result.success) + return { + success: false, + error: result.error || 'Signup failed', + needsVerification: false, + }; + if (result.needsVerification) return { success: true, needsVerification: true }; + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + needsVerification: false, + }; + } + }, + + // Sign Out + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + try { + await authService.signOut(); + user = null; + if (config.onSignOut) await config.onSignOut(); + } catch (error) { + console.error('Sign out error:', error); + user = null; + } + }, + + // Password Reset + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + try { + const redirectTo = browser ? window.location.origin : undefined; + const result = await authService.forgotPassword(email, redirectTo); + if (!result.success) + return { success: false, error: result.error || 'Password reset failed' }; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + + async resetPasswordWithToken(token: string, newPassword: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + try { + const result = await authService.resetPassword(token, newPassword); + if (!result.success) + return { success: false, error: result.error || 'Failed to reset password' }; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + + // Token access + async getAccessToken() { + const authService = getAuthService(); + if (!authService) return null; + return await authService.getAppToken(); + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) return null; + return await tokenManager.getValidToken(); + }, + + // Email verification + async resendVerificationEmail(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.resendVerificationEmail(email, sourceAppUrl); + if (!result.success) + return { success: false, error: result.error || 'Failed to send verification email' }; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }, + }; +} + +export type ManaAuthStore = ReturnType; diff --git a/packages/shared-auth-ui/src/stores/store-types.ts b/packages/shared-auth-ui/src/stores/store-types.ts new file mode 100644 index 000000000..b88856964 --- /dev/null +++ b/packages/shared-auth-ui/src/stores/store-types.ts @@ -0,0 +1,92 @@ +/** + * Shared Auth Store Types + * Generic types for creating auth stores with custom user types + */ + +/** + * Base user interface that all app-specific user types must extend + */ +export interface BaseUser { + id: string; + email: string; +} + +/** + * Auth operation result with typed user + */ +export interface AuthResult { + success: boolean; + error?: string; + needsVerification?: boolean; + user?: TUser; +} + +/** + * Auth service interface that auth stores expect + * Apps implement this to integrate with their auth backend + */ +export interface AuthServiceAdapter { + /** Check if user is authenticated */ + isAuthenticated(): Promise; + + /** Get user data from stored token/session */ + getUserFromToken(): Promise; + + /** Sign in with email and password */ + signIn(email: string, password: string): Promise>; + + /** Sign up with email and password */ + signUp(email: string, password: string): Promise>; + + /** Send password reset email */ + forgotPassword(email: string): Promise<{ success: boolean; error?: string }>; + + /** Sign out user */ + signOut(): Promise; +} + +/** + * Auth store state interface + */ +export interface AuthStoreState { + user: TUser | null; + loading: boolean; + error: string | null; + isAuthenticated: boolean; +} + +/** + * Auth store actions interface + */ +export interface AuthStoreActions { + /** Initialize auth state from stored tokens */ + initialize(): Promise; + + /** Set user manually */ + setUser(user: TUser | null): void; + + /** Sign in with email and password */ + signIn(email: string, password: string): Promise>; + + /** Sign up with email and password */ + signUp(email: string, password: string): Promise>; + + /** Send password reset email */ + forgotPassword(email: string): Promise<{ success: boolean; error?: string }>; + + /** Sign out user */ + signOut(): Promise; + + /** Check authentication status */ + checkAuth(): Promise; + + /** Clear error state */ + clearError(): void; +} + +/** + * Complete auth store interface + */ +export interface AuthStore + extends AuthStoreState, + AuthStoreActions {}