diff --git a/packages/shared-auth-stores/package.json b/packages/shared-auth-stores/package.json new file mode 100644 index 000000000..831387c60 --- /dev/null +++ b/packages/shared-auth-stores/package.json @@ -0,0 +1,32 @@ +{ + "name": "@manacore/shared-auth-stores", + "version": "0.0.1", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "svelte": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "svelte": "./src/index.ts", + "files": [ + "src" + ], + "scripts": { + "type-check": "svelte-check --tsconfig ./tsconfig.json" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "devDependencies": { + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "@manacore/shared-types": "workspace:*" + } +} diff --git a/packages/shared-auth-stores/src/createAuthStore.svelte.ts b/packages/shared-auth-stores/src/createAuthStore.svelte.ts new file mode 100644 index 000000000..ea8633bc5 --- /dev/null +++ b/packages/shared-auth-stores/src/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-stores/src/createSupabaseAuthStore.svelte.ts b/packages/shared-auth-stores/src/createSupabaseAuthStore.svelte.ts new file mode 100644 index 000000000..f972ba205 --- /dev/null +++ b/packages/shared-auth-stores/src/createSupabaseAuthStore.svelte.ts @@ -0,0 +1,271 @@ +/** + * Supabase Auth Store Factory + * + * Creates a reactive auth store that works directly with Supabase client. + * Useful for apps that use Supabase directly without middleware. + * + * @example + * ```ts + * import { createSupabaseAuthStore } from '@manacore/shared-auth-stores'; + * import { createBrowserClient } from '@supabase/ssr'; + * + * const supabase = createBrowserClient(url, key); + * export const authStore = createSupabaseAuthStore(supabase); + * ``` + */ + +import type { BaseUser, AuthResult } from './types'; + +/** + * Minimal Supabase client interface + * Only requires the auth methods we use + */ +interface SupabaseClientLike { + auth: { + getSession(): Promise<{ data: { session: { user: { id: string; email?: string } } | null } }>; + signInWithPassword(credentials: { + email: string; + password: string; + }): Promise<{ error: { message: string } | null }>; + signUp(credentials: { + email: string; + password: string; + }): Promise<{ data: { session: unknown }; error: { message: string } | null }>; + resetPasswordForEmail( + email: string, + options?: { redirectTo?: string } + ): Promise<{ error: { message: string } | null }>; + signOut(): Promise<{ error: { message: string } | null }>; + }; +} + +/** + * Options for creating a Supabase auth store + */ +export interface CreateSupabaseAuthStoreOptions { + /** URL to redirect to after password reset */ + passwordResetRedirectUrl?: string; +} + +/** + * Default user type for Supabase auth + */ +export interface SupabaseUser extends BaseUser { + id: string; + email: string; +} + +/** + * Create a Svelte 5 runes-based auth store for Supabase + * + * @param getSupabaseClient - Function that returns a Supabase client + * @param options - Configuration options + * @returns Reactive auth store with state and actions + */ +export function createSupabaseAuthStore( + getSupabaseClient: () => SupabaseClientLike, + options: CreateSupabaseAuthStoreOptions = {} +) { + // Reactive state using Svelte 5 runes + let user = $state(null); + let loading = $state(true); + let error = $state(null); + + /** + * Get user from current session + */ + async function getUserFromSession(): Promise { + const supabase = getSupabaseClient(); + const { + data: { session } + } = await supabase.auth.getSession(); + if (!session?.user) return null; + return { + id: session.user.id, + email: session.user.email || '' + }; + } + + return { + // Reactive getters + get user() { + return user; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get isAuthenticated() { + return !!user; + }, + + /** + * Initialize auth state from Supabase session + */ + async initialize() { + loading = true; + error = null; + try { + user = await getUserFromSession(); + } 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 + */ + setUser(newUser: SupabaseUser | null) { + user = newUser; + error = null; + }, + + /** + * Sign in with email and password + */ + async signIn(email: string, password: string): Promise> { + loading = true; + error = null; + try { + const supabase = getSupabaseClient(); + const { error: authError } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (authError) { + error = authError.message; + return { success: false, error: authError.message }; + } + + user = await getUserFromSession(); + return { success: true, user: user || undefined }; + } 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 supabase = getSupabaseClient(); + const { data, error: authError } = await supabase.auth.signUp({ + email, + password + }); + + if (authError) { + error = authError.message; + return { success: false, error: authError.message }; + } + + // Check if email confirmation is required + const needsVerification = !data.session; + + if (!needsVerification) { + user = await getUserFromSession(); + } + + return { + success: true, + needsVerification, + user: user || undefined + }; + } 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 supabase = getSupabaseClient(); + const redirectTo = + options.passwordResetRedirectUrl || `${window.location.origin}/reset-password`; + + const { error: authError } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo + }); + + if (authError) { + error = authError.message; + return { success: false, error: authError.message }; + } + + return { success: true }; + } 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 { + const supabase = getSupabaseClient(); + await supabase.auth.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 sessionUser = await getUserFromSession(); + if (!sessionUser) { + user = null; + return false; + } + user = sessionUser; + 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-stores/src/index.ts b/packages/shared-auth-stores/src/index.ts new file mode 100644 index 000000000..380de57ad --- /dev/null +++ b/packages/shared-auth-stores/src/index.ts @@ -0,0 +1,42 @@ +/** + * @manacore/shared-auth-stores + * + * Svelte 5 auth store factories for the ManaCore monorepo. + * + * Provides two approaches: + * 1. createAuthStore - Generic factory that works with any auth service adapter + * 2. createSupabaseAuthStore - Direct Supabase integration for simpler setups + * + * @example Generic auth store with custom adapter + * ```ts + * import { createAuthStore } from '@manacore/shared-auth-stores'; + * import { authService } from '$lib/auth'; + * import type { AppUser } from '$lib/types'; + * + * export const authStore = createAuthStore(authService); + * ``` + * + * @example Supabase auth store + * ```ts + * import { createSupabaseAuthStore } from '@manacore/shared-auth-stores'; + * import { createBrowserClient } from '@supabase/ssr'; + * + * const getClient = () => createBrowserClient(url, key); + * export const authStore = createSupabaseAuthStore(getClient); + * ``` + */ + +// Factory functions +export { createAuthStore } from './createAuthStore.svelte'; +export { createSupabaseAuthStore } from './createSupabaseAuthStore.svelte'; +export type { CreateSupabaseAuthStoreOptions, SupabaseUser } from './createSupabaseAuthStore.svelte'; + +// Types +export type { + BaseUser, + AuthResult, + AuthServiceAdapter, + AuthStoreState, + AuthStoreActions, + AuthStore +} from './types'; diff --git a/packages/shared-auth-stores/src/types.ts b/packages/shared-auth-stores/src/types.ts new file mode 100644 index 000000000..f88a3f3d9 --- /dev/null +++ b/packages/shared-auth-stores/src/types.ts @@ -0,0 +1,94 @@ +/** + * Shared Auth Store Types + * Generic types for creating auth stores with custom user types + */ + +import type { AuthResult as BaseAuthResult } from '@manacore/shared-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 {} diff --git a/packages/shared-auth-stores/tsconfig.json b/packages/shared-auth-stores/tsconfig.json new file mode 100644 index 000000000..96d56cbb1 --- /dev/null +++ b/packages/shared-auth-stores/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-types/src/auth.ts b/packages/shared-types/src/auth.ts index 31658b70e..1f844fe1f 100644 --- a/packages/shared-types/src/auth.ts +++ b/packages/shared-types/src/auth.ts @@ -7,6 +7,65 @@ */ export type AuthState = 'loading' | 'authenticated' | 'unauthenticated'; +/** + * Standard authentication error codes + */ +export type AuthErrorCode = + | 'INVALID_CREDENTIALS' + | 'EMAIL_NOT_CONFIRMED' + | 'USER_NOT_FOUND' + | 'EMAIL_ALREADY_EXISTS' + | 'WEAK_PASSWORD' + | 'INVALID_EMAIL' + | 'RATE_LIMITED' + | 'TOKEN_EXPIRED' + | 'TOKEN_INVALID' + | 'NETWORK_ERROR' + | 'SERVER_ERROR' + | 'UNKNOWN_ERROR'; + +/** + * Structured authentication error + */ +export interface AuthError { + code: AuthErrorCode; + message: string; + originalError?: unknown; +} + +/** + * Map common Supabase error messages to AuthErrorCode + */ +export function mapSupabaseErrorToCode(message: string): AuthErrorCode { + const lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes('invalid login credentials')) return 'INVALID_CREDENTIALS'; + if (lowerMessage.includes('email not confirmed')) return 'EMAIL_NOT_CONFIRMED'; + if (lowerMessage.includes('user not found')) return 'USER_NOT_FOUND'; + if (lowerMessage.includes('already registered') || lowerMessage.includes('already exists')) + return 'EMAIL_ALREADY_EXISTS'; + if (lowerMessage.includes('password') && lowerMessage.includes('weak')) return 'WEAK_PASSWORD'; + if (lowerMessage.includes('invalid email')) return 'INVALID_EMAIL'; + if (lowerMessage.includes('rate') || lowerMessage.includes('too many')) return 'RATE_LIMITED'; + if (lowerMessage.includes('token') && lowerMessage.includes('expired')) return 'TOKEN_EXPIRED'; + if (lowerMessage.includes('token') && lowerMessage.includes('invalid')) return 'TOKEN_INVALID'; + if (lowerMessage.includes('network') || lowerMessage.includes('fetch')) return 'NETWORK_ERROR'; + if (lowerMessage.includes('server') || lowerMessage.includes('500')) return 'SERVER_ERROR'; + + return 'UNKNOWN_ERROR'; +} + +/** + * Create an AuthError from a Supabase error message + */ +export function createAuthError(message: string, originalError?: unknown): AuthError { + return { + code: mapSupabaseErrorToCode(message), + message, + originalError + }; +} + /** * User session */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c5fdeb2..9a21ddd34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1576,6 +1576,22 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/shared-auth-stores: + dependencies: + '@manacore/shared-types': + specifier: workspace:* + version: link:../shared-types + devDependencies: + svelte: + specifier: ^5.0.0 + version: 5.43.14 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.43.14)(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/shared-auth-ui: dependencies: '@manacore/shared-auth':