feat: add Tier 3 shared auth store patterns

- Create @manacore/shared-auth-stores package with Svelte 5 factories:
  - createAuthStore<T>(): Generic factory with custom auth service adapter
  - createSupabaseAuthStore(): Direct Supabase integration for simpler setups
- Add standardized auth error types to @manacore/shared-types:
  - AuthErrorCode enum for consistent error handling
  - mapSupabaseErrorToCode() helper function
  - createAuthError() factory function

This enables apps to share auth state management while supporting
both middleware-based auth (ManaCore/Manadeck) and direct Supabase
auth (ManaCore-web simple mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-24 23:54:27 +01:00
parent 10325026f9
commit 9449fff6f7
8 changed files with 717 additions and 0 deletions

View file

@ -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:*"
}
}

View file

@ -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<AppUser>(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<TUser extends BaseUser>(authService: AuthServiceAdapter<TUser>) {
// Reactive state using Svelte 5 runes
let user = $state<TUser | null>(null);
let loading = $state(true);
let error = $state<string | null>(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<AuthResult<TUser>> {
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<AuthResult<TUser>> {
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<boolean> {
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;
}
};
}

View file

@ -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<SupabaseUser | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
/**
* Get user from current session
*/
async function getUserFromSession(): Promise<SupabaseUser | null> {
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<AuthResult<SupabaseUser>> {
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<AuthResult<SupabaseUser>> {
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<boolean> {
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;
}
};
}

View file

@ -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<AppUser>(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';

View file

@ -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<TUser extends BaseUser = BaseUser> {
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<TUser extends BaseUser = BaseUser> {
/** Check if user is authenticated */
isAuthenticated(): Promise<boolean>;
/** Get user data from stored token/session */
getUserFromToken(): Promise<TUser | null>;
/** Sign in with email and password */
signIn(email: string, password: string): Promise<AuthResult<TUser>>;
/** Sign up with email and password */
signUp(email: string, password: string): Promise<AuthResult<TUser>>;
/** Send password reset email */
forgotPassword(email: string): Promise<{ success: boolean; error?: string }>;
/** Sign out user */
signOut(): Promise<void>;
}
/**
* Auth store state interface
*/
export interface AuthStoreState<TUser extends BaseUser = BaseUser> {
user: TUser | null;
loading: boolean;
error: string | null;
isAuthenticated: boolean;
}
/**
* Auth store actions interface
*/
export interface AuthStoreActions<TUser extends BaseUser = BaseUser> {
/** Initialize auth state from stored tokens */
initialize(): Promise<void>;
/** Set user manually */
setUser(user: TUser | null): void;
/** Sign in with email and password */
signIn(email: string, password: string): Promise<AuthResult<TUser>>;
/** Sign up with email and password */
signUp(email: string, password: string): Promise<AuthResult<TUser>>;
/** Send password reset email */
forgotPassword(email: string): Promise<{ success: boolean; error?: string }>;
/** Sign out user */
signOut(): Promise<void>;
/** Check authentication status */
checkAuth(): Promise<boolean>;
/** Clear error state */
clearError(): void;
}
/**
* Complete auth store interface
*/
export interface AuthStore<TUser extends BaseUser = BaseUser>
extends AuthStoreState<TUser>,
AuthStoreActions<TUser> {}

View file

@ -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"]
}

View file

@ -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
*/

16
pnpm-lock.yaml generated
View file

@ -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':