(getInitialMode());
+
+ const effectiveMode = derived(mode, ($mode) => {
+ if ($mode === 'system') {
+ return getSystemPreference();
+ }
+ return $mode;
+ });
+
+ const state = derived([mode, effectiveMode], ([$mode, $effectiveMode]) => ({
+ mode: $mode,
+ effectiveMode: $effectiveMode
+ }));
+
+ // Apply theme to document
+ if (browser) {
+ effectiveMode.subscribe((effective) => {
+ if (effective === 'dark') {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ });
+
+ // Listen for system preference changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ mode.update((m) => m); // Trigger re-evaluation
+ });
+ }
+
+ return {
+ subscribe: state.subscribe,
+ setMode: (newMode: ThemeMode) => {
+ mode.set(newMode);
+ if (browser) {
+ localStorage.setItem('theme-mode', newMode);
+ }
+ },
+ toggleMode: () => {
+ mode.update((current) => {
+ const newMode = current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light';
+ if (browser) {
+ localStorage.setItem('theme-mode', newMode);
+ }
+ return newMode;
+ });
+ }
+ };
+}
+
+export const theme = createThemeStore();
diff --git a/manacore/apps/web/src/routes/(auth)/+layout.svelte b/manacore/apps/web/src/routes/(auth)/+layout.svelte
index 783c2bac8..9cbdab613 100644
--- a/manacore/apps/web/src/routes/(auth)/+layout.svelte
+++ b/manacore/apps/web/src/routes/(auth)/+layout.svelte
@@ -11,8 +11,4 @@
});
-
-
- {@render children()}
-
-
+{@render children()}
diff --git a/manacore/apps/web/src/routes/(auth)/login/+page.server.ts b/manacore/apps/web/src/routes/(auth)/login/+page.server.ts
deleted file mode 100644
index 479aec505..000000000
--- a/manacore/apps/web/src/routes/(auth)/login/+page.server.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { redirect, fail } from '@sveltejs/kit';
-import type { Actions } from './$types';
-
-export const actions: Actions = {
- default: async ({ request, locals: { supabase } }) => {
- const formData = await request.formData();
- const email = formData.get('email') as string;
- const password = formData.get('password') as string;
-
- if (!email || !password) {
- return fail(400, {
- error: 'Email and password are required',
- email
- });
- }
-
- const { error } = await supabase.auth.signInWithPassword({
- email,
- password
- });
-
- if (error) {
- console.error('Login error:', error);
- return fail(400, {
- error: error.message,
- email
- });
- }
-
- throw redirect(303, '/dashboard');
- }
-};
diff --git a/manacore/apps/web/src/routes/(auth)/login/+page.svelte b/manacore/apps/web/src/routes/(auth)/login/+page.svelte
index 04952fe34..38f1901f2 100644
--- a/manacore/apps/web/src/routes/(auth)/login/+page.svelte
+++ b/manacore/apps/web/src/routes/(auth)/login/+page.svelte
@@ -1,86 +1,29 @@
-
-
-
ManaCore
-
Sign in to your account
-
-
-
-
-
-
-
- Don't have an account?
-
- Sign up
-
-
-
-
-
+
diff --git a/manacore/apps/web/src/routes/(auth)/register/+page.server.ts b/manacore/apps/web/src/routes/(auth)/register/+page.server.ts
deleted file mode 100644
index d4b4293c1..000000000
--- a/manacore/apps/web/src/routes/(auth)/register/+page.server.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { fail } from '@sveltejs/kit';
-import type { Actions } from './$types';
-
-export const actions: Actions = {
- default: async ({ request, locals: { supabase } }) => {
- const formData = await request.formData();
- const email = formData.get('email') as string;
- const password = formData.get('password') as string;
- const confirmPassword = formData.get('confirmPassword') as string;
-
- if (!email || !password || !confirmPassword) {
- return fail(400, {
- error: 'All fields are required',
- email
- });
- }
-
- if (password !== confirmPassword) {
- return fail(400, {
- error: 'Passwords do not match',
- email
- });
- }
-
- if (password.length < 8) {
- return fail(400, {
- error: 'Password must be at least 8 characters',
- email
- });
- }
-
- const { error } = await supabase.auth.signUp({
- email,
- password,
- options: {
- emailRedirectTo: `${new URL('/auth/callback', request.url).toString()}`
- }
- });
-
- if (error) {
- console.error('Registration error:', error);
- return fail(400, {
- error: error.message,
- email
- });
- }
-
- return {
- success: true
- };
- }
-};
diff --git a/manacore/apps/web/src/routes/(auth)/register/+page.svelte b/manacore/apps/web/src/routes/(auth)/register/+page.svelte
index 8d268155b..3ec45a1d6 100644
--- a/manacore/apps/web/src/routes/(auth)/register/+page.svelte
+++ b/manacore/apps/web/src/routes/(auth)/register/+page.svelte
@@ -1,101 +1,22 @@
-
-
-
Create Account
-
Sign up for ManaCore
-
-
-
-
-
-
-
- Already have an account?
-
- Sign in
-
-
-
-
-
+
diff --git a/manacore/apps/web/src/routes/(auth)/reset-password/+page.svelte b/manacore/apps/web/src/routes/(auth)/reset-password/+page.svelte
index 5914781ae..484109da3 100644
--- a/manacore/apps/web/src/routes/(auth)/reset-password/+page.svelte
+++ b/manacore/apps/web/src/routes/(auth)/reset-password/+page.svelte
@@ -136,12 +136,6 @@
{/if}
- {#if form?.success}
-
- Password updated successfully! Redirecting to dashboard...
-
- {/if}
-
@@ -154,7 +148,7 @@
autocomplete="new-password"
placeholder="••••••••"
required
- minlength="6"
+ minlength={6}
/>
Must be at least 6 characters
@@ -172,7 +166,7 @@
autocomplete="new-password"
placeholder="••••••••"
required
- minlength="6"
+ minlength={6}
/>
diff --git a/manacore/apps/web/src/routes/auth/reset-password/+page.svelte b/manacore/apps/web/src/routes/auth/reset-password/+page.svelte
index 0693be34f..c89d74e9a 100644
--- a/manacore/apps/web/src/routes/auth/reset-password/+page.svelte
+++ b/manacore/apps/web/src/routes/auth/reset-password/+page.svelte
@@ -141,12 +141,6 @@
{/if}
- {#if form?.success}
-
- Password updated successfully! Redirecting to dashboard...
-
- {/if}
-
@@ -159,7 +153,7 @@
autocomplete="new-password"
placeholder="••••••••"
required
- minlength="6"
+ minlength={6}
/>
Must be at least 6 characters
@@ -177,7 +171,7 @@
autocomplete="new-password"
placeholder="••••••••"
required
- minlength="6"
+ minlength={6}
/>
diff --git a/manacore/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png b/manacore/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png
new file mode 100644
index 000000000..e47ad9138
Binary files /dev/null and b/manacore/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png differ
diff --git a/manacore/apps/web/static/images/app-icons/manacore-logo-gradient.png b/manacore/apps/web/static/images/app-icons/manacore-logo-gradient.png
new file mode 100644
index 000000000..7bb2798b3
Binary files /dev/null and b/manacore/apps/web/static/images/app-icons/manacore-logo-gradient.png differ
diff --git a/manacore/apps/web/static/images/app-icons/memoro-logo-gradient.png b/manacore/apps/web/static/images/app-icons/memoro-logo-gradient.png
new file mode 100644
index 000000000..f7bbee22d
Binary files /dev/null and b/manacore/apps/web/static/images/app-icons/memoro-logo-gradient.png differ
diff --git a/manacore/apps/web/static/images/app-icons/moodlit-logo-gradient.png b/manacore/apps/web/static/images/app-icons/moodlit-logo-gradient.png
new file mode 100644
index 000000000..69fcd68a1
Binary files /dev/null and b/manacore/apps/web/static/images/app-icons/moodlit-logo-gradient.png differ
diff --git a/manacore/apps/web/tailwind.config.js b/manacore/apps/web/tailwind.config.js
index d5b680605..1689d8bf2 100644
--- a/manacore/apps/web/tailwind.config.js
+++ b/manacore/apps/web/tailwind.config.js
@@ -1,9 +1,17 @@
+import preset from '@manacore/shared-tailwind/preset';
+
/** @type {import('tailwindcss').Config} */
export default {
- content: ['./src/**/*.{html,js,svelte,ts}'],
+ presets: [preset],
+ content: [
+ './src/**/*.{html,js,svelte,ts}',
+ '../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
+ '../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
+ ],
theme: {
extend: {
colors: {
+ // ManaCore specific primary blue
primary: {
50: '#eff6ff',
100: '#dbeafe',
@@ -19,6 +27,5 @@ export default {
}
}
}
- },
- plugins: []
+ }
};
diff --git a/manadeck/apps/web/package.json b/manadeck/apps/web/package.json
index 0f6c430ea..ed16dccd5 100644
--- a/manadeck/apps/web/package.json
+++ b/manadeck/apps/web/package.json
@@ -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"
}
}
diff --git a/manadeck/apps/web/src/lib/auth.ts b/manadeck/apps/web/src/lib/auth.ts
new file mode 100644
index 000000000..69d32e821
--- /dev/null
+++ b/manadeck/apps/web/src/lib/auth.ts
@@ -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
(key: string): Promise {
+ 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 {
+ if (typeof window === 'undefined') return;
+ sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
+ },
+
+ async removeItem(key: string): Promise {
+ if (typeof window === 'undefined') return;
+ sessionStorage.removeItem(key);
+ },
+};
+
+/**
+ * Device manager adapter for web
+ */
+const webDeviceAdapter: DeviceManagerAdapter = {
+ async getDeviceInfo(): Promise {
+ 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 {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
+ },
+};
+
+/**
+ * Network adapter for web
+ */
+const webNetworkAdapter: NetworkAdapter = {
+ async isDeviceConnected(): Promise {
+ if (typeof navigator === 'undefined') return true;
+ return navigator.onLine;
+ },
+
+ async hasStableConnection(): Promise {
+ 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';
diff --git a/manadeck/apps/web/src/lib/components/Icon.svelte b/manadeck/apps/web/src/lib/components/Icon.svelte
new file mode 100644
index 000000000..967807016
--- /dev/null
+++ b/manadeck/apps/web/src/lib/components/Icon.svelte
@@ -0,0 +1,34 @@
+
+
+{#if path}
+
+ {@html path}
+
+{:else}
+ ⚠
+{/if}
diff --git a/manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte b/manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
new file mode 100644
index 000000000..05573986d
--- /dev/null
+++ b/manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manadeck/apps/web/src/lib/services/authService.ts b/manadeck/apps/web/src/lib/services/authService.ts
deleted file mode 100644
index 7ae6fc3b8..000000000
--- a/manadeck/apps/web/src/lib/services/authService.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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();
- }
-};
diff --git a/manadeck/apps/web/src/lib/services/deviceManager.ts b/manadeck/apps/web/src/lib/services/deviceManager.ts
deleted file mode 100644
index d1bc729a4..000000000
--- a/manadeck/apps/web/src/lib/services/deviceManager.ts
+++ /dev/null
@@ -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
- };
-}
diff --git a/manadeck/apps/web/src/lib/services/tokenManager.ts b/manadeck/apps/web/src/lib/services/tokenManager.ts
deleted file mode 100644
index 5a313e4a2..000000000
--- a/manadeck/apps/web/src/lib/services/tokenManager.ts
+++ /dev/null
@@ -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 | 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 {
- // 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 {
- 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 {
- 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();
diff --git a/manadeck/apps/web/src/lib/stores/authStore.svelte.ts b/manadeck/apps/web/src/lib/stores/authStore.svelte.ts
index 42bc3d721..fd036f6cf 100644
--- a/manadeck/apps/web/src/lib/stores/authStore.svelte.ts
+++ b/manadeck/apps/web/src/lib/stores/authStore.svelte.ts
@@ -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(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);
}
};
diff --git a/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts b/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts
index 9fbb013bf..235f1d7ea 100644
--- a/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts
+++ b/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts
@@ -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([]);
@@ -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);
diff --git a/manadeck/apps/web/src/lib/utils/jwt.ts b/manadeck/apps/web/src/lib/utils/jwt.ts
deleted file mode 100644
index a46c4db4c..000000000
--- a/manadeck/apps/web/src/lib/utils/jwt.ts
+++ /dev/null
@@ -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
- };
-}
diff --git a/manadeck/apps/web/src/routes/(app)/profile/+page.svelte b/manadeck/apps/web/src/routes/(app)/profile/+page.svelte
index 53f709f95..4edd54589 100644
--- a/manadeck/apps/web/src/routes/(app)/profile/+page.svelte
+++ b/manadeck/apps/web/src/routes/(app)/profile/+page.svelte
@@ -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 {
diff --git a/manadeck/apps/web/src/routes/(auth)/login/+page.svelte b/manadeck/apps/web/src/routes/(auth)/login/+page.svelte
index 74d122b4b..b734b7328 100644
--- a/manadeck/apps/web/src/routes/(auth)/login/+page.svelte
+++ b/manadeck/apps/web/src/routes/(auth)/login/+page.svelte
@@ -1,83 +1,29 @@
-
- Sign In - Manadeck
-
-
-
-
-
-
Welcome back
-
Sign in to your Manadeck account
-
-
-
-
-
-
-
Don't have an account?
-
- Sign up
-
-
-
-
-
+
diff --git a/manadeck/apps/web/src/routes/(auth)/register/+page.svelte b/manadeck/apps/web/src/routes/(auth)/register/+page.svelte
index 4742e8221..9270e7d9d 100644
--- a/manadeck/apps/web/src/routes/(auth)/register/+page.svelte
+++ b/manadeck/apps/web/src/routes/(auth)/register/+page.svelte
@@ -1,112 +1,22 @@
-
- Sign Up - Manadeck
-
-
-
-
-
-
Create your account
-
Start building your knowledge decks
-
-
-
-
-
-
-
Already have an account?
-
- Sign in
-
-
-
-
-
+
diff --git a/manadeck/apps/web/tailwind.config.js b/manadeck/apps/web/tailwind.config.js
index 87004324e..d96e48c6d 100644
--- a/manadeck/apps/web/tailwind.config.js
+++ b/manadeck/apps/web/tailwind.config.js
@@ -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))',
diff --git a/memoro/apps/web/package.json b/memoro/apps/web/package.json
index 2e58a6e57..67528031e 100644
--- a/memoro/apps/web/package.json
+++ b/memoro/apps/web/package.json
@@ -16,7 +16,6 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
- "@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
@@ -26,6 +25,18 @@
"vite": "^7.1.7"
},
"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:*",
"@phosphor-icons/core": "^2.1.1",
"@supabase/supabase-js": "^2.81.1",
"date-fns": "^4.1.0",
diff --git a/memoro/apps/web/src/app.css b/memoro/apps/web/src/app.css
index ffdc704f5..cedfeceb0 100644
--- a/memoro/apps/web/src/app.css
+++ b/memoro/apps/web/src/app.css
@@ -1,408 +1,7 @@
+@import '@manacore/shared-tailwind/theme.css';
+
@tailwind base;
@tailwind components;
@tailwind utilities;
-:root {
- --font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
- Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
- --font-mono: 'Fira Mono', monospace;
-}
-
-/* Default Theme: Lume Light */
-:root {
- --color-primary: #f8d62b;
- --color-primary-button: #f8d62b;
- --color-primary-button-text: #000000;
- --color-secondary: #d4b200;
- --color-secondary-button: #ffe9a3;
- --color-content-bg: #ffffff;
- --color-content-bg-hover: #f5f5f5;
- --color-content-page-bg: #ffffff;
- --color-menu-bg: #dddddd;
- --color-menu-bg-hover: #cccccc;
- --color-panel-bg: #e8e8e8;
- --color-page-bg: #dddddd;
- --color-text: #2c2c2c;
- --color-border-light: #f2f2f2;
- --color-border: #999999;
- --color-border-strong: #cccccc;
- --color-error: #e74c3c;
-}
-
-/* Lume Dark */
-:root.dark {
- --color-primary: #f8d62b;
- --color-primary-button: #7c6b16;
- --color-primary-button-text: #ffffff;
- --color-secondary: #d4b200;
- --color-secondary-button: #1e1e1e;
- --color-content-bg: #1e1e1e;
- --color-content-bg-hover: #333333;
- --color-content-page-bg: #121212;
- --color-menu-bg: #101010;
- --color-menu-bg-hover: #333333;
- --color-panel-bg: #1a1a1a;
- --color-page-bg: #101010;
- --color-text: #ffffff;
- --color-border-light: #333333;
- --color-border: #424242;
- --color-border-strong: #616161;
- --color-error: #e74c3c;
-}
-
-/* Nature Light */
-:root[data-theme='nature'] {
- --color-primary: #4caf50;
- --color-primary-button: #a08500;
- --color-primary-button-text: #ffffff;
- --color-secondary: #81c784;
- --color-secondary-button: #f1f8e9;
- --color-content-bg: #f1f8e9;
- --color-content-bg-hover: #e8f5e9;
- --color-content-page-bg: #ffffff;
- --color-menu-bg: #e8f5e9;
- --color-menu-bg-hover: #c8e6c9;
- --color-panel-bg: #eff8f0;
- --color-page-bg: #fbfdf8;
- --color-text: #1b5e20;
- --color-border-light: #e8f5e9;
- --color-border: #c8e6c9;
- --color-border-strong: #a5d6a7;
- --color-error: #e57373;
-}
-
-/* Nature Dark */
-:root[data-theme='nature'].dark {
- --color-primary: #4caf50;
- --color-primary-button: #ff9500;
- --color-primary-button-text: #000000;
- --color-secondary: #81c784;
- --color-secondary-button: #1e1e1e;
- --color-content-bg: #1e1e1e;
- --color-content-bg-hover: #2e7d32;
- --color-content-page-bg: #121212;
- --color-menu-bg: #252525;
- --color-menu-bg-hover: #2e7d32;
- --color-panel-bg: #2a2a2a;
- --color-page-bg: #121212;
- --color-text: #ffffff;
- --color-border-light: #1b5e20;
- --color-border: #2e7d32;
- --color-border-strong: #388e3c;
- --color-error: #cf6679;
-}
-
-/* Stone Light */
-:root[data-theme='stone'] {
- --color-primary: #607d8b;
- --color-primary-button: #ff9500;
- --color-primary-button-text: #000000;
- --color-secondary: #90a4ae;
- --color-secondary-button: #eceff1;
- --color-content-bg: #eceff1;
- --color-content-bg-hover: #e0e6ea;
- --color-content-page-bg: #ffffff;
- --color-menu-bg: #e0e6ea;
- --color-menu-bg-hover: #cfd8dc;
- --color-panel-bg: #e8edf0;
- --color-page-bg: #f5f7f9;
- --color-text: #263238;
- --color-border-light: #eceff1;
- --color-border: #cfd8dc;
- --color-border-strong: #b0bec5;
- --color-error: #ef5350;
-}
-
-/* Stone Dark */
-:root[data-theme='stone'].dark {
- --color-primary: #78909c;
- --color-primary-button: #ff9500;
- --color-primary-button-text: #000000;
- --color-secondary: #90a4ae;
- --color-secondary-button: #1e1e1e;
- --color-content-bg: #1e1e1e;
- --color-content-bg-hover: #37474f;
- --color-content-page-bg: #121212;
- --color-menu-bg: #252525;
- --color-menu-bg-hover: #37474f;
- --color-panel-bg: #2a2a2a;
- --color-page-bg: #121212;
- --color-text: #ffffff;
- --color-border-light: #37474f;
- --color-border: #455a64;
- --color-border-strong: #546e7a;
- --color-error: #cf6679;
-}
-
-/* Ocean Light */
-:root[data-theme='ocean'] {
- --color-primary: #039be5;
- --color-primary-button: #ff9500;
- --color-primary-button-text: #000000;
- --color-secondary: #4fc3f7;
- --color-secondary-button: #e1f5fe;
- --color-content-bg: #e1f5fe;
- --color-content-bg-hover: #b3e5fc;
- --color-content-page-bg: #ffffff;
- --color-menu-bg: #e1f5fe;
- --color-menu-bg-hover: #b3e5fc;
- --color-panel-bg: #ecf8fe;
- --color-page-bg: #f5fcff;
- --color-text: #01579b;
- --color-border-light: #e1f5fe;
- --color-border: #b3e5fc;
- --color-border-strong: #81d4fa;
- --color-error: #ef5350;
-}
-
-/* Ocean Dark */
-:root[data-theme='ocean'].dark {
- --color-primary: #039be5;
- --color-primary-button: #ff9500;
- --color-primary-button-text: #000000;
- --color-secondary: #4fc3f7;
- --color-secondary-button: #1e1e1e;
- --color-content-bg: #1e1e1e;
- --color-content-bg-hover: #0277bd;
- --color-content-page-bg: #121212;
- --color-menu-bg: #252525;
- --color-menu-bg-hover: #0277bd;
- --color-panel-bg: #2a2a2a;
- --color-page-bg: #121212;
- --color-text: #ffffff;
- --color-border-light: #01579b;
- --color-border: #0277bd;
- --color-border-strong: #0288d1;
- --color-error: #cf6679;
-}
-
-body {
- margin: 0;
- font-family: var(--font-body);
-}
-
-html {
- margin: 0;
- padding: 0;
-}
-
-@layer base {
- h1 {
- @apply text-3xl font-bold;
- color: var(--color-text);
- }
- h2 {
- @apply text-2xl font-semibold;
- color: var(--color-text);
- }
- h3 {
- @apply text-xl font-semibold;
- color: var(--color-text);
- }
-}
-
-@layer components {
- .btn-primary {
- @apply rounded-lg px-4 py-2 font-semibold transition-colors;
- background-color: var(--color-primary-button);
- color: var(--color-primary-button-text);
- }
-
- .btn-primary:hover {
- opacity: 0.9;
- }
-
- .btn-secondary {
- @apply rounded-lg px-4 py-2 font-semibold transition-colors;
- background-color: var(--color-secondary-button);
- color: var(--color-text);
- border: 1px solid var(--color-border);
- }
-
- .btn-secondary:hover {
- background-color: var(--color-content-bg-hover);
- }
-
- .btn-danger {
- @apply rounded-lg px-4 py-2 font-semibold transition-colors;
- background-color: var(--color-error);
- color: #ffffff;
- }
-
- .btn-danger:hover {
- opacity: 0.9;
- }
-
- .input-field {
- @apply w-full rounded-lg px-4 py-2 transition-colors;
- background-color: var(--color-content-bg);
- color: var(--color-text);
- border: 1px solid var(--color-border);
- }
-
- .input-field:focus {
- outline: none;
- border-color: var(--color-primary);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
- }
-
- .card {
- @apply rounded-lg p-6 shadow-sm;
- background-color: var(--color-content-bg);
- color: var(--color-text);
- }
-
- /* Header & Navigation */
- .header-style {
- background-color: var(--color-menu-bg);
- border-bottom: 1px solid var(--color-border);
- }
-
- .logo-text {
- @apply text-2xl font-bold;
- color: var(--color-primary);
- }
-
- .nav-link {
- @apply transition-colors;
- color: var(--color-text);
- }
-
- .nav-link:hover {
- color: var(--color-primary);
- }
-
- .user-email {
- @apply text-sm;
- color: var(--color-text);
- opacity: 0.7;
- }
-
- /* Main Content Area */
- .main-content {
- background-color: var(--color-page-bg);
- }
-
- /* Selected/Active State */
- .bg-selected {
- background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
- }
-
- /* Status Badge Colors */
- .status-completed {
- background-color: rgba(76, 175, 80, 0.15);
- color: #4caf50;
- }
-
- .status-processing {
- background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
- color: var(--color-primary);
- }
-
- .status-failed {
- background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
- color: var(--color-error);
- }
-
- .status-default {
- background-color: color-mix(in srgb, var(--color-text) 10%, transparent);
- color: var(--color-text);
- }
-
- /* Info/Alert Boxes */
- .info-box {
- background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
- border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
- }
-
- /* Loading Spinner */
- .spinner-border {
- border-color: var(--color-primary);
- }
-
- /* Focus Ring */
- .focus\:ring-primary:focus {
- --tw-ring-color: var(--color-primary);
- }
-
- .focus\:ring-2:focus {
- box-shadow: 0 0 0 2px var(--tw-ring-color, var(--color-primary));
- }
-}
-
-@layer utilities {
- /* Theme Color Utilities - in utilities layer for @apply support */
- .bg-content {
- background-color: var(--color-content-bg);
- }
-
- .bg-content-hover {
- background-color: var(--color-content-bg-hover);
- }
-
- .hover\:bg-content-hover:hover {
- background-color: var(--color-content-bg-hover);
- }
-
- .bg-menu {
- background-color: var(--color-menu-bg);
- }
-
- .bg-menu-hover {
- background-color: var(--color-menu-bg-hover);
- }
-
- .hover\:bg-menu-hover:hover {
- background-color: var(--color-menu-bg-hover);
- }
-
- .bg-panel {
- background-color: var(--color-panel-bg);
- }
-
- .bg-page {
- background-color: var(--color-page-bg);
- }
-
- .border-theme {
- border-color: var(--color-border);
- }
-
- .border-theme-light {
- border-color: var(--color-border-light);
- }
-
- .text-theme {
- color: var(--color-text);
- }
-
- .text-theme-secondary {
- color: var(--color-text);
- opacity: 0.6;
- }
-
- .text-theme-muted {
- color: var(--color-text);
- opacity: 0.4;
- }
-
- .text-primary {
- color: var(--color-primary);
- }
-
- .bg-primary {
- background-color: var(--color-primary);
- }
-
- .bg-primary-button {
- background-color: var(--color-primary-button);
- }
-
- .text-primary-button-text {
- color: var(--color-primary-button-text);
- }
-
- .bg-secondary-button {
- background-color: var(--color-secondary-button);
- }
-}
+@import '@manacore/shared-tailwind/components.css';
diff --git a/memoro/apps/web/src/lib/components/AdviceCarousel.svelte b/memoro/apps/web/src/lib/components/AdviceCarousel.svelte
index d284070a9..2f06426ea 100644
--- a/memoro/apps/web/src/lib/components/AdviceCarousel.svelte
+++ b/memoro/apps/web/src/lib/components/AdviceCarousel.svelte
@@ -69,7 +69,7 @@
}
if (data && data.advice) {
- advice = data.advice as AdviceData;
+ advice = data.advice as unknown as AdviceData;
currentIndex = 0; // Reset to first section
} else {
advice = null;
diff --git a/memoro/apps/web/src/lib/components/AppSlider.svelte b/memoro/apps/web/src/lib/components/AppSlider.svelte
index b5efd6db9..bbc9e1262 100644
--- a/memoro/apps/web/src/lib/components/AppSlider.svelte
+++ b/memoro/apps/web/src/lib/components/AppSlider.svelte
@@ -123,6 +123,7 @@
$effect(() => {
if (selectedApp !== null && modalScrollContainer) {
setTimeout(() => {
+ if (selectedApp === null) return;
const cardWidth = 360 + 24; // card width + gap
const scrollPosition = selectedApp * cardWidth;
modalScrollContainer?.scrollTo({
diff --git a/memoro/apps/web/src/lib/components/AudioPlayer.svelte b/memoro/apps/web/src/lib/components/AudioPlayer.svelte
index b05647395..366f36d49 100644
--- a/memoro/apps/web/src/lib/components/AudioPlayer.svelte
+++ b/memoro/apps/web/src/lib/components/AudioPlayer.svelte
@@ -1,7 +1,7 @@
@@ -25,7 +22,7 @@
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
- fill="currentColor"
+ fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
diff --git a/memoro/apps/web/src/lib/components/SettingsToggle.svelte b/memoro/apps/web/src/lib/components/SettingsToggle.svelte
index b90678c77..5bb6644db 100644
--- a/memoro/apps/web/src/lib/components/SettingsToggle.svelte
+++ b/memoro/apps/web/src/lib/components/SettingsToggle.svelte
@@ -1,5 +1,5 @@
-
-
diff --git a/memoro/apps/web/src/lib/components/atoms/index.ts b/memoro/apps/web/src/lib/components/atoms/index.ts
deleted file mode 100644
index e5b69ce46..000000000
--- a/memoro/apps/web/src/lib/components/atoms/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Text } from './Text.svelte';
diff --git a/memoro/apps/web/src/lib/components/audio/ArchiveStatistics.svelte b/memoro/apps/web/src/lib/components/audio/ArchiveStatistics.svelte
index 7ea2dd6a1..50721f12c 100644
--- a/memoro/apps/web/src/lib/components/audio/ArchiveStatistics.svelte
+++ b/memoro/apps/web/src/lib/components/audio/ArchiveStatistics.svelte
@@ -1,7 +1,7 @@
-
-
-
-
-
-
-
-
-
-```
-
-## Available Icons
-
-See `iconPaths.ts` for the complete list of available icons. Some commonly used icons:
-
-### Auth & User
-- `user`, `user-plus`, `users`, `sign-in`, `sign-out`
-
-### Navigation
-- `arrow-left`, `arrow-right`, `arrow-up`, `arrow-down`
-- `caret-down`, `caret-up`, `caret-left`, `caret-right`
-
-### Actions
-- `plus`, `minus`, `x`, `check`
-- `trash`, `copy`, `share`
-- `download`, `upload`
-
-### Media
-- `play`, `pause`, `microphone`
-
-### Edit
-- `pencil`, `pen`, `note-pencil`
-
-### Files & Folders
-- `folder`, `folder-open`, `file`
-
-### UI Elements
-- `dots-three`, `dots-three-vertical`, `list`
-- `magnifying-glass`, `eye`, `eye-slash`
-
-### Misc
-- `key`, `tag`, `link`, `lock`
-- `star`, `heart`, `bell`
-- `calendar`, `clock`, `image`
-
-## Adding New Icons
-
-1. Find the icon you need at [phosphoricons.com](https://phosphoricons.com/)
-2. Locate the bold version in `node_modules/@phosphor-icons/core/assets/bold/`
-3. Copy the SVG `` content
-4. Add it to `iconPaths.ts`:
-
-```typescript
-export const iconPaths = {
- // ... existing icons
- 'new-icon': ' ',
-}
-```
-
-5. Use it:
-
-```svelte
-
-```
-
-## Icon Weight
-
-All icons use the **Bold** weight for consistency across the application. This provides:
-- Better visibility
-- Consistent visual hierarchy
-- Improved readability at smaller sizes
-
-## TypeScript Support
-
-The Icon component is fully typed. TypeScript will autocomplete icon names and show errors if you use an icon that doesn't exist.
-
-```svelte
-
-
-
-
-
-```
-
-## Best Practices
-
-1. **Use semantic names**: Icons should have clear, descriptive names
-2. **Consistent sizing**: Stick to common sizes (16, 20, 24, 32, 40)
-3. **Color inheritance**: Icons use `currentColor` - control color via text color classes
-4. **Accessibility**: Icons are marked with `aria-hidden="true"` - provide text alternatives when needed
-
-## Examples
-
-### Button with Icon
-```svelte
-
-
- Add Item
-
-```
-
-### Icon Button
-```svelte
-
-
-
-```
-
-### Loading State
-```svelte
-
- {#if loading}
-
- {:else}
-
- {/if}
- Upload
-
-```
diff --git a/memoro/apps/web/src/lib/components/icons/iconPaths.ts b/memoro/apps/web/src/lib/components/icons/iconPaths.ts
deleted file mode 100644
index 4cb151ee9..000000000
--- a/memoro/apps/web/src/lib/components/icons/iconPaths.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Phosphor Icons (Bold weight) - Official icons from @phosphor-icons/core
- *
- * This is a centralized icon catalog for the entire application.
- * All icons use the Bold weight for consistency.
- *
- * To add new icons:
- * 1. Find the icon in node_modules/@phosphor-icons/core/assets/bold/
- * 2. Copy the SVG content (the tag)
- * 3. Add it to this file with a descriptive key
- *
- * Usage:
- * import Icon from '$lib/components/Icon.svelte';
- *
- */
-
-export const iconPaths = {
- // Auth & User
- 'user-plus': ' ',
- 'sign-in': ' ',
- 'sign-out': ' ',
- 'user': ' ',
- 'users': ' ',
-
- // Navigation & Arrows
- 'arrow-left': ' ',
- 'arrow-right': ' ',
- 'arrow-up': ' ',
- 'arrow-down': ' ',
- 'caret-down': ' ',
- 'caret-up': ' ',
- 'caret-left': ' ',
- 'caret-right': ' ',
-
- // Actions
- 'plus': ' ',
- 'minus': ' ',
- 'x': ' ',
- 'check': ' ',
- 'trash': ' ',
- 'copy': ' ',
-
- // Media
- 'play': ' ',
- 'pause': ' ',
- 'microphone': ' ',
- 'skip-back': ' ',
- 'skip-forward': ' ',
-
- // Edit
- 'pencil': ' ',
- 'pen': ' ',
- 'note-pencil': ' ',
-
- // Files & Folders
- 'folder': ' ',
- 'folder-open': ' ',
- 'file': ' ',
-
- // UI Elements
- 'dots-three': ' ',
- 'dots-three-vertical': ' ',
- 'list': ' ',
- 'magnifying-glass': ' ',
-
- // Misc
- 'key': ' ',
- 'info': ' ',
- 'tag': ' ',
- 'share': ' ',
- 'download': ' ',
- 'upload': ' ',
- 'link': ' ',
- 'eye': ' ',
- 'eye-slash': ' ',
- 'lock': ' ',
- 'star': ' ',
- 'heart': ' ',
- 'bell': ' ',
- 'calendar': ' ',
- 'clock': ' ',
- 'image': ' ',
- 'shield-check': ' ',
- 'envelope': ' ',
- 'arrows-left-right': ' ',
-} as const;
-
-export type IconName = keyof typeof iconPaths;
diff --git a/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte b/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte
index 17616c597..952017922 100644
--- a/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte
+++ b/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte
@@ -1,18 +1,10 @@
-
- Login - Memoro
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#if mode === 'initial'}
-
-
-
- {$t('auth.mana_login')}
- {:else if mode === 'login'}
- {$t('auth.sign_in')}
- {:else if mode === 'forgot-password'}
- {$t('auth.reset_password')}
- {:else if mode === 'password-reset-success'}
- {$t('auth.reset_email_sent_title')}
- {/if}
-
- {#if mode === 'initial'}
-
-
- {$t('auth.mana_login_description')}
-
-
showManaInfoModal = true}
- class="flex items-center gap-2 text-xs font-medium hover:opacity-80 transition-opacity"
- style="color: {isDark ? '#ffffff' : '#000000'};"
- >
-
- Mehr erfahren
-
-
- {/if}
-
-
-
- {#if error}
-
- {/if}
-
- {#if oauthError}
-
- {/if}
-
-
- {#if mode === 'initial'}
-
- goto('/register')}
- class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
- style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
- >
-
- {$t('auth.create_account')}
-
-
- switchMode('login')}
- class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all bg-content hover:bg-menu-hover text-theme border border-theme"
- >
-
- {$t('auth.sign_in')}
-
-
-
-
-
-
- {@html $t('auth.terms_agreement')}
-
-
-
-
- {:else if mode === 'login'}
-
-
-
-
-
-
{$t('common.or')}
-
-
-
-
-
-
-
-
- {
- resetForm();
- switchMode('initial');
- }}
- class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:bg-menu-hover text-theme"
- >
-
- {$t('auth.back')}
-
-
-
-
- {:else if mode === 'forgot-password'}
-
-
-
- {:else if mode === 'password-reset-success'}
-
-
-
-
-
-
-
- {$t('auth.reset_email_sent_description').replace('{email}', resetEmail)}
-
-
-
-
- {
- resetEmail = '';
- switchMode('login');
- }}
- class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
- style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
- >
-
- {$t('auth.back_to_login')}
-
-
- switchMode('forgot-password')}
- class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all bg-content hover:bg-menu-hover text-theme border border-theme"
- >
- {$t('auth.resend_email')}
-
-
-
- {/if}
-
-
-
-
-
- {#if mode === 'initial'}
-
- {/if}
-
-
-
showManaInfoModal = false}
- title="Mana Login"
- maxWidth="lg"
- >
- {#snippet icon()}
-
-
-
- {/snippet}
-
- {#snippet children()}
-
-
- Mana Login ist dein zentraler Zugang zu allen Apps im Mana-Ökosystem. Mit einem
- einzigen Account kannst du dich bei allen Mana-Anwendungen anmelden.
-
-
-
-
Vorteile:
-
-
-
-
-
-
{$t('auth.mana_login_benefit_0')}
-
-
-
-
- Ein Login für alle Mana Apps
-
-
-
-
- Sichere Authentifizierung mit modernen Standards
-
-
-
-
- Synchronisation deiner Einstellungen über alle Apps hinweg
-
-
-
-
- Einfache Verwaltung deiner Daten an einem zentralen Ort
-
-
-
-
- Weitere Mana Apps werden in Zukunft hinzugefügt und können dann ebenfalls mit deinem
- Mana Login genutzt werden.
-
-
- {/snippet}
-
- {#snippet footer()}
- showManaInfoModal = false}
- class="w-full px-6 py-2 rounded-xl font-medium bg-content hover:bg-menu-hover text-theme border border-theme transition-colors"
- >
- Verstanden
-
- {/snippet}
-
-
+
diff --git a/memoro/apps/web/src/routes/(public)/register/+page.svelte b/memoro/apps/web/src/routes/(public)/register/+page.svelte
index 4c4221f48..34490262b 100644
--- a/memoro/apps/web/src/routes/(public)/register/+page.svelte
+++ b/memoro/apps/web/src/routes/(public)/register/+page.svelte
@@ -1,408 +1,22 @@
-
- {$t('auth.create_account')} - Memoro
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {$t('auth.create_account')}
-
-
-
- {#if error}
-
- {/if}
-
- {#if success && needsVerification}
-
-
- {$t('auth.registration_success')}
-
-
- {/if}
-
- {#if oauthError}
-
- {/if}
-
-
-
-
-
-
-
- {$t('auth.email_only_info')}
-
-
showModal = true}
- class="flex items-center gap-2 text-xs font-medium hover:opacity-80 transition-opacity"
- style="color: {isDark ? '#ffffff' : '#000000'};"
- >
-
- {$t('auth.email_only_learn_more')}
-
-
-
-
-
- goto('/login')}
- class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all"
- style="color: {isDark ? '#ffffff' : '#000000'};"
- >
-
- {$t('common.back')}
-
-
-
-
-
-
-
-
-
- showModal = false} title={$t('auth.email_only_title')} maxWidth="lg">
- {#snippet children()}
-
-
- {$t('auth.email_only_intro')}
-
-
-
-
-
-
-
-
-
{$t('auth.email_only_benefit_1_title')}
-
{$t('auth.email_only_benefit_1_desc')}
-
-
-
-
-
-
-
-
-
{$t('auth.email_only_benefit_2_title')}
-
{$t('auth.email_only_benefit_2_desc')}
-
-
-
-
-
-
-
-
-
{$t('auth.email_only_benefit_3_title')}
-
{$t('auth.email_only_benefit_3_desc')}
-
-
-
-
-
-
-
-
-
{$t('auth.email_only_benefit_4_title')}
-
{$t('auth.email_only_benefit_4_desc')}
-
-
-
-
-
- {$t('auth.email_only_modal_footer')}
-
-
- {/snippet}
-
- {#snippet footer()}
- showModal = false}
- class="w-full px-6 py-2 rounded-xl font-medium bg-content hover:bg-menu-hover text-theme border border-theme transition-colors"
- >
- {$t('auth.got_it')}
-
- {/snippet}
-
+
diff --git a/memoro/apps/web/src/routes/+page.svelte b/memoro/apps/web/src/routes/+page.svelte
index d3ca00f20..c2556ad56 100644
--- a/memoro/apps/web/src/routes/+page.svelte
+++ b/memoro/apps/web/src/routes/+page.svelte
@@ -1,8 +1,13 @@
-
{#if sdkLoaded}
{#if error}
@@ -60,7 +49,6 @@
{/if}
-
import { onMount } from 'svelte';
- import { goto } from '$app/navigation';
- import { auth } from '$lib/stores/auth';
- import { initializeGoogleAuth, renderGoogleButton, waitForGoogleAuth } from '$lib/utils/googleAuth';
+ import { initializeGoogleAuth, renderGoogleButton, waitForGoogleAuth } from '../utils/googleAuth';
- // Props
interface Props {
- onSuccess?: () => void;
+ onSuccess: (idToken: string) => Promise;
onError?: (error: Error) => void;
}
let { onSuccess, onError }: Props = $props();
- // State
let buttonContainer: HTMLDivElement;
let isLoading = $state(false);
let error = $state(null);
- // Handle Google Sign-In callback
async function handleGoogleSignIn(idToken: string) {
isLoading = true;
error = null;
try {
- console.log('Google Sign-In successful, received ID token');
-
- // Call auth store's signInWithGoogle method
- // This handles everything: middleware call, token storage, state update
- const result = await auth.signInWithGoogle(idToken);
-
- if (!result.success) {
- throw new Error(result.error || 'Failed to authenticate with Google');
- }
-
- console.log('Successfully authenticated with middleware');
-
- // Navigate to dashboard
- goto('/dashboard');
-
- onSuccess?.();
+ await onSuccess(idToken);
} catch (err) {
console.error('Error during Google Sign-In:', err);
error = err instanceof Error ? err.message : 'Google Sign-In failed';
@@ -48,16 +28,11 @@
}
}
- // Initialize Google Sign-In on mount
onMount(async () => {
try {
- // Wait for Google Identity Services to load
await waitForGoogleAuth();
-
- // Initialize with callback
initializeGoogleAuth(handleGoogleSignIn);
- // Render the button
if (buttonContainer) {
renderGoogleButton(buttonContainer, {
type: 'standard',
@@ -80,11 +55,10 @@
{/if}
-
{#if isLoading}
-
@@ -100,10 +74,3 @@
border-radius: 0.75rem !important;
}
-
-
-
-
- Please enable JavaScript to use Google Sign-In
-
-
diff --git a/packages/shared-auth-ui/src/components/Icon.svelte b/packages/shared-auth-ui/src/components/Icon.svelte
new file mode 100644
index 000000000..808ea0a04
--- /dev/null
+++ b/packages/shared-auth-ui/src/components/Icon.svelte
@@ -0,0 +1,30 @@
+
+
+{#if path}
+
+ {@html path}
+
+{:else}
+
⚠
+{/if}
diff --git a/packages/shared-auth-ui/src/icons/iconPaths.ts b/packages/shared-auth-ui/src/icons/iconPaths.ts
new file mode 100644
index 000000000..9d060a982
--- /dev/null
+++ b/packages/shared-auth-ui/src/icons/iconPaths.ts
@@ -0,0 +1,37 @@
+/**
+ * Phosphor Icons (Bold weight) SVG paths
+ * Only includes icons used in auth UI
+ */
+export const iconPaths = {
+ 'user-plus': '
',
+
+ 'sign-in': '
',
+
+ 'eye': '
',
+
+ 'eye-off': '
',
+
+ 'key': '
',
+
+ 'arrow-left': '
',
+
+ 'info': '
',
+
+ 'mail-open': '
',
+
+ 'lock': '
',
+
+ 'shield-check': '
',
+
+ 'arrows-left-right': '
',
+
+ 'envelope': '
',
+
+ 'folder': '
',
+
+ 'music': '
',
+
+ 'refresh': '
'
+} as const;
+
+export type IconName = keyof typeof iconPaths;
diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts
new file mode 100644
index 000000000..426ed291b
--- /dev/null
+++ b/packages/shared-auth-ui/src/index.ts
@@ -0,0 +1,40 @@
+// Pages
+export { default as LoginPage } from './pages/LoginPage.svelte';
+export { default as RegisterPage } from './pages/RegisterPage.svelte';
+
+// Components
+export { default as Icon } from './components/Icon.svelte';
+export { default as GoogleSignInButton } from './components/GoogleSignInButton.svelte';
+export { default as AppleSignInButton } from './components/AppleSignInButton.svelte';
+
+// Utilities
+export {
+ setGoogleClientId,
+ initializeGoogleAuth,
+ renderGoogleButton,
+ isGoogleAuthLoaded,
+ waitForGoogleAuth
+} from './utils/googleAuth';
+
+export {
+ setAppleConfig,
+ initializeAppleAuth,
+ signInWithApple,
+ parseAppleAuthorizationResponse,
+ getStoredReturnUrl,
+ clearAppleSignInSession,
+ isAppleAuthLoaded,
+ waitForAppleAuth,
+ type AppleAuthorizationResponse
+} from './utils/appleAuth';
+
+// Types
+export type {
+ AuthUIConfig,
+ AuthServiceInterface,
+ AuthResult,
+ IconName
+} from './types';
+
+// Icon paths
+export { iconPaths } from './icons/iconPaths';
diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte
new file mode 100644
index 000000000..14d83dce4
--- /dev/null
+++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte
@@ -0,0 +1,438 @@
+
+
+
+ Login - {appName}
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if mode === 'initial'}
+ Mana Login
+ {:else if mode === 'login'}
+ Sign In
+ {:else if mode === 'forgot-password'}
+ Reset Password
+ {:else if mode === 'password-reset-success'}
+ Email Sent
+ {/if}
+
+ {#if mode === 'initial'}
+
+ Sign in with your Mana account
+
+ {/if}
+
+
+
+ {#if error}
+
+ {/if}
+
+
+ {#if mode === 'initial'}
+
+ goto(registerPath)}
+ class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
+ style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
+ >
+
+ Create Account
+
+
+ switchMode('login')}
+ class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
+ style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
+ >
+
+ Sign In
+
+
+
+
+ {:else if mode === 'login'}
+
+
+
+ {#if enableGoogle || enableApple}
+
+
+
+ {#if enableGoogle && onSignInWithGoogle}
+
+ {/if}
+ {#if enableApple}
+
+ {/if}
+
+ {/if}
+
+
+
+ {
+ resetForm();
+ switchMode('initial');
+ }}
+ class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
+ style="color: {isDark ? '#ffffff' : '#000000'};"
+ >
+
+ Back
+
+
+
+
+ {:else if mode === 'forgot-password'}
+
+
+
+ {:else if mode === 'password-reset-success'}
+
+
+
+
+
+
+
+ We've sent a password reset link to {resetEmail} . Please check your
+ inbox.
+
+
+
+
+ {
+ resetEmail = '';
+ switchMode('login');
+ }}
+ class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
+ style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
+ >
+
+ Back to Login
+
+
+ switchMode('forgot-password')}
+ class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
+ style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
+ >
+ Resend Email
+
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/packages/shared-auth-ui/src/pages/RegisterPage.svelte b/packages/shared-auth-ui/src/pages/RegisterPage.svelte
new file mode 100644
index 000000000..4929cab75
--- /dev/null
+++ b/packages/shared-auth-ui/src/pages/RegisterPage.svelte
@@ -0,0 +1,310 @@
+
+
+
+ Create Account - {appName}
+
+
+
+
+
+
+
+
+
+
+
+ Create Account
+
+
+
+ {#if error}
+
+ {/if}
+
+
+ {#if success && needsVerification}
+
+
+ Account created! Please check your email to verify your account.
+
+
+ {/if}
+
+
+
+
+
+
+ goto(loginPath)}
+ class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
+ style="color: {isDark ? '#ffffff' : '#000000'};"
+ >
+
+ Back to Login
+
+
+
+
+
+
+
+
diff --git a/packages/shared-auth-ui/src/types.ts b/packages/shared-auth-ui/src/types.ts
new file mode 100644
index 000000000..95712ddf3
--- /dev/null
+++ b/packages/shared-auth-ui/src/types.ts
@@ -0,0 +1,80 @@
+import type { Component } from 'svelte';
+
+/**
+ * Configuration for auth UI pages
+ */
+export interface AuthUIConfig {
+ /** App name to display */
+ appName: string;
+
+ /** Logo component to render */
+ logo: Component<{ size?: number; color?: string }>;
+
+ /** Primary color (hex) */
+ primaryColor: string;
+
+ /** Primary color for dark mode (optional, defaults to primaryColor) */
+ darkPrimaryColor?: string;
+
+ /** Page background color for light mode */
+ lightBackground?: string;
+
+ /** Page background color for dark mode */
+ darkBackground?: string;
+
+ /** Redirect path after successful login (default: '/dashboard') */
+ successRedirect?: string;
+
+ /** Enable Google Sign-In */
+ enableGoogle?: boolean;
+
+ /** Enable Apple Sign-In */
+ enableApple?: boolean;
+
+ /** Google OAuth Client ID (required if enableGoogle is true) */
+ googleClientId?: string;
+
+ /** Apple OAuth Service ID (required if enableApple is true) */
+ appleClientId?: string;
+
+ /** Apple OAuth Redirect URI */
+ appleRedirectUri?: string;
+}
+
+/**
+ * Auth service interface expected by the UI components
+ */
+export interface AuthServiceInterface {
+ signIn(email: string, password: string): Promise
;
+ signUp(email: string, password: string): Promise;
+ signInWithGoogle?(idToken: string): Promise;
+ signInWithApple?(identityToken: string): Promise;
+ forgotPassword(email: string): Promise;
+}
+
+/**
+ * Result from auth operations
+ */
+export interface AuthResult {
+ success: boolean;
+ error?: string;
+ needsVerification?: boolean;
+}
+
+/**
+ * Icon names available in the icon set
+ */
+export type IconName =
+ | 'user-plus'
+ | 'sign-in'
+ | 'eye'
+ | 'eye-off'
+ | 'key'
+ | 'arrow-left'
+ | 'info'
+ | 'mail-open'
+ | 'lock'
+ | 'shield-check'
+ | 'arrows-left-right'
+ | 'envelope'
+ | 'folder';
diff --git a/packages/shared-auth-ui/src/utils/appleAuth.ts b/packages/shared-auth-ui/src/utils/appleAuth.ts
new file mode 100644
index 000000000..e2f0f0904
--- /dev/null
+++ b/packages/shared-auth-ui/src/utils/appleAuth.ts
@@ -0,0 +1,216 @@
+/**
+ * Apple Sign-In integration for web
+ * Uses redirect flow (not popup)
+ */
+
+// TypeScript definitions for Apple ID SDK
+declare global {
+ interface Window {
+ AppleID?: {
+ auth: {
+ init: (config: AppleIDInitConfig) => void;
+ signIn: () => Promise;
+ };
+ };
+ }
+}
+
+interface AppleIDInitConfig {
+ clientId: string;
+ scope: string;
+ redirectURI: string;
+ state?: string;
+ nonce?: string;
+ usePopup?: boolean;
+ responseType?: string;
+ responseMode?: string;
+}
+
+interface AppleIDSignInResponse {
+ authorization: {
+ code: string;
+ id_token?: string;
+ state?: string;
+ };
+ user?: {
+ email?: string;
+ name?: {
+ firstName?: string;
+ lastName?: string;
+ };
+ };
+}
+
+export interface AppleAuthorizationResponse {
+ code: string;
+ id_token?: string;
+ state?: string;
+ user?: string;
+}
+
+let appleClientId: string | null = null;
+let appleRedirectUri: string | null = null;
+
+/**
+ * Set Apple Sign-In configuration
+ */
+export function setAppleConfig(clientId: string, redirectUri: string) {
+ appleClientId = clientId;
+ appleRedirectUri = redirectUri;
+}
+
+/**
+ * Check if running in browser
+ */
+function isBrowser(): boolean {
+ return typeof window !== 'undefined';
+}
+
+/**
+ * Initialize Apple ID SDK
+ */
+export function initializeAppleAuth(): boolean {
+ if (!isBrowser() || !window.AppleID) {
+ console.warn('Apple ID SDK not loaded');
+ return false;
+ }
+
+ if (!appleClientId || !appleRedirectUri) {
+ console.error('Apple Sign-In not configured. Call setAppleConfig() first.');
+ return false;
+ }
+
+ try {
+ window.AppleID.auth.init({
+ clientId: appleClientId,
+ scope: 'name email',
+ redirectURI: appleRedirectUri,
+ state: generateState(),
+ usePopup: false,
+ responseType: 'code id_token',
+ responseMode: 'form_post'
+ });
+
+ console.log('Apple ID SDK initialized successfully');
+ return true;
+ } catch (error) {
+ console.error('Error initializing Apple ID SDK:', error);
+ return false;
+ }
+}
+
+/**
+ * Initiate Apple Sign-In (redirect flow)
+ */
+export async function signInWithApple(): Promise {
+ if (!isBrowser()) {
+ throw new Error('Apple Sign-In only available in browser');
+ }
+
+ if (!window.AppleID) {
+ throw new Error('Apple ID SDK not loaded');
+ }
+
+ try {
+ const returnTo = window.location.pathname + window.location.search;
+ sessionStorage.setItem('apple_signin_return_to', returnTo);
+ await window.AppleID.auth.signIn();
+ } catch (error) {
+ console.error('Error initiating Apple Sign-In:', error);
+ throw error;
+ }
+}
+
+/**
+ * Parse Apple authorization response from URL
+ */
+export function parseAppleAuthorizationResponse(
+ urlParams: URLSearchParams
+): AppleAuthorizationResponse | null {
+ const code = urlParams.get('code');
+ const id_token = urlParams.get('id_token');
+ const state = urlParams.get('state');
+ const user = urlParams.get('user');
+ const error = urlParams.get('error');
+
+ if (error) {
+ console.error('Apple Sign-In error:', error);
+ return null;
+ }
+
+ const storedState = sessionStorage.getItem('apple_signin_state');
+ if (state !== storedState) {
+ console.error('State mismatch - possible CSRF attack');
+ return null;
+ }
+
+ if (!id_token && !code) {
+ console.error('No id_token or authorization code in Apple response');
+ return null;
+ }
+
+ return {
+ code: code || '',
+ id_token: id_token || undefined,
+ state: state || undefined,
+ user: user || undefined
+ };
+}
+
+/**
+ * Generate random state for CSRF protection
+ */
+function generateState(): string {
+ const state = Math.random().toString(36).substring(2, 15);
+ if (isBrowser()) {
+ sessionStorage.setItem('apple_signin_state', state);
+ }
+ return state;
+}
+
+/**
+ * Get stored return URL
+ */
+export function getStoredReturnUrl(): string {
+ if (!isBrowser()) return '/dashboard';
+ return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
+}
+
+/**
+ * Clear Apple Sign-In session data
+ */
+export function clearAppleSignInSession() {
+ if (!isBrowser()) return;
+ sessionStorage.removeItem('apple_signin_state');
+ sessionStorage.removeItem('apple_signin_return_to');
+}
+
+/**
+ * Check if Apple ID SDK is loaded
+ */
+export function isAppleAuthLoaded(): boolean {
+ return isBrowser() && !!window.AppleID?.auth;
+}
+
+/**
+ * Wait for Apple ID SDK to load
+ */
+export function waitForAppleAuth(timeout = 10000): Promise {
+ return new Promise((resolve, reject) => {
+ if (isAppleAuthLoaded()) {
+ resolve();
+ return;
+ }
+
+ const startTime = Date.now();
+ const interval = setInterval(() => {
+ if (isAppleAuthLoaded()) {
+ clearInterval(interval);
+ resolve();
+ } else if (Date.now() - startTime > timeout) {
+ clearInterval(interval);
+ reject(new Error('Apple ID SDK failed to load'));
+ }
+ }, 100);
+ });
+}
diff --git a/packages/shared-auth-ui/src/utils/googleAuth.ts b/packages/shared-auth-ui/src/utils/googleAuth.ts
new file mode 100644
index 000000000..8e21cdb53
--- /dev/null
+++ b/packages/shared-auth-ui/src/utils/googleAuth.ts
@@ -0,0 +1,174 @@
+/**
+ * Google Identity Services integration
+ * Provides helper functions for Google Sign-In on web
+ */
+
+// TypeScript definitions for Google Identity Services
+declare global {
+ interface Window {
+ google?: {
+ accounts: {
+ id: {
+ initialize: (config: GoogleIdConfiguration) => void;
+ prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
+ renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
+ disableAutoSelect: () => void;
+ storeCredential: (credential: { id: string; password: string }) => void;
+ cancel: () => void;
+ onGoogleLibraryLoad: () => void;
+ revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
+ };
+ };
+ };
+ }
+}
+
+interface GoogleIdConfiguration {
+ client_id: string;
+ callback: (response: CredentialResponse) => void;
+ auto_select?: boolean;
+ cancel_on_tap_outside?: boolean;
+ context?: 'signin' | 'signup' | 'use';
+ ux_mode?: 'popup' | 'redirect';
+ login_uri?: string;
+ native_callback?: (response: { id: string; password: string }) => void;
+ itp_support?: boolean;
+}
+
+interface CredentialResponse {
+ credential: string;
+ select_by: string;
+ clientId?: string;
+}
+
+interface GsiButtonConfiguration {
+ type?: 'standard' | 'icon';
+ theme?: 'outline' | 'filled_blue' | 'filled_black';
+ size?: 'large' | 'medium' | 'small';
+ text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
+ shape?: 'rectangular' | 'pill' | 'circle' | 'square';
+ logo_alignment?: 'left' | 'center';
+ width?: string;
+ locale?: string;
+}
+
+interface PromptMomentNotification {
+ isDisplayMoment: () => boolean;
+ isDisplayed: () => boolean;
+ isNotDisplayed: () => boolean;
+ getNotDisplayedReason: () => string;
+ isSkippedMoment: () => boolean;
+ getSkippedReason: () => string;
+ isDismissedMoment: () => boolean;
+ getDismissedReason: () => string;
+ getMomentType: () => 'display' | 'skipped' | 'dismissed';
+}
+
+interface RevocationResponse {
+ successful: boolean;
+ error?: string;
+}
+
+let googleClientId: string | null = null;
+
+/**
+ * Set Google Client ID for initialization
+ */
+export function setGoogleClientId(clientId: string) {
+ googleClientId = clientId;
+}
+
+/**
+ * Initialize Google Identity Services
+ */
+export function initializeGoogleAuth(callback: (idToken: string) => void) {
+ if (typeof window === 'undefined') {
+ console.warn('Google Auth: Cannot initialize on server-side');
+ return;
+ }
+
+ if (!window.google) {
+ console.warn('Google Identity Services not loaded yet');
+ return;
+ }
+
+ if (!googleClientId) {
+ console.error('Google Client ID not configured. Call setGoogleClientId() first.');
+ return;
+ }
+
+ try {
+ window.google.accounts.id.initialize({
+ client_id: googleClientId,
+ callback: (response: CredentialResponse) => {
+ callback(response.credential);
+ },
+ auto_select: false,
+ cancel_on_tap_outside: true,
+ ux_mode: 'popup'
+ });
+ } catch (error) {
+ console.error('Error initializing Google Auth:', error);
+ }
+}
+
+/**
+ * Render Google Sign-In button
+ */
+export function renderGoogleButton(
+ element: HTMLElement,
+ options?: Partial
+) {
+ if (typeof window === 'undefined' || !window.google) {
+ console.warn('Google Identity Services not available');
+ return;
+ }
+
+ const defaultOptions: GsiButtonConfiguration = {
+ type: 'standard',
+ theme: 'outline',
+ size: 'large',
+ text: 'signin_with',
+ shape: 'rectangular',
+ logo_alignment: 'left'
+ };
+
+ try {
+ window.google.accounts.id.renderButton(element, {
+ ...defaultOptions,
+ ...options
+ });
+ } catch (error) {
+ console.error('Error rendering Google button:', error);
+ }
+}
+
+/**
+ * Check if Google Identity Services is loaded
+ */
+export function isGoogleAuthLoaded(): boolean {
+ return typeof window !== 'undefined' && !!window.google?.accounts?.id;
+}
+
+/**
+ * Wait for Google Identity Services to load
+ */
+export function waitForGoogleAuth(timeout = 10000): Promise {
+ return new Promise((resolve, reject) => {
+ if (isGoogleAuthLoaded()) {
+ resolve();
+ return;
+ }
+
+ const startTime = Date.now();
+ const interval = setInterval(() => {
+ if (isGoogleAuthLoaded()) {
+ clearInterval(interval);
+ resolve();
+ } else if (Date.now() - startTime > timeout) {
+ clearInterval(interval);
+ reject(new Error('Google Identity Services failed to load'));
+ }
+ }, 100);
+ });
+}
diff --git a/packages/shared-auth-ui/tsconfig.json b/packages/shared-auth-ui/tsconfig.json
new file mode 100644
index 000000000..0d1efc56c
--- /dev/null
+++ b/packages/shared-auth-ui/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/shared-auth/README.md b/packages/shared-auth/README.md
new file mode 100644
index 000000000..049fe46dd
--- /dev/null
+++ b/packages/shared-auth/README.md
@@ -0,0 +1,210 @@
+# @manacore/shared-auth
+
+Shared authentication utilities for Manacore apps. This package provides a configurable authentication service that can be used across React Native (Expo) and web apps.
+
+## Features
+
+- **Configurable Auth Service**: Create auth services with custom base URLs and endpoints
+- **Token Manager**: Handle token refresh, queueing, and state management
+- **JWT Utilities**: Decode tokens, check expiration, extract user data
+- **Fetch Interceptor**: Automatically attach tokens and handle 401 responses
+- **Platform Adapters**: Pluggable storage, device, and network adapters
+
+## Installation
+
+```bash
+pnpm add @manacore/shared-auth
+```
+
+## Quick Start
+
+### Web (SvelteKit, React, etc.)
+
+```typescript
+import { initializeWebAuth } from '@manacore/shared-auth';
+
+const { authService, tokenManager } = initializeWebAuth({
+ baseUrl: 'https://api.example.com',
+});
+
+// Sign in
+const result = await authService.signIn('user@example.com', 'password');
+if (result.success) {
+ console.log('Signed in!');
+}
+
+// Get current user
+const user = await authService.getUserFromToken();
+console.log(user?.email);
+
+// Sign out
+await authService.signOut();
+```
+
+### React Native (Expo)
+
+```typescript
+import {
+ createAuthService,
+ createTokenManager,
+ setStorageAdapter,
+ setDeviceAdapter,
+ setNetworkAdapter,
+ setupFetchInterceptor,
+} from '@manacore/shared-auth';
+import * as SecureStore from 'expo-secure-store';
+
+// Create storage adapter for Expo
+const expoStorageAdapter = {
+ async getItem(key: string): Promise {
+ const value = await SecureStore.getItemAsync(key);
+ if (!value) return null;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return value as T;
+ }
+ },
+ async setItem(key: string, value: string): Promise {
+ await SecureStore.setItemAsync(key, value);
+ },
+ async removeItem(key: string): Promise {
+ await SecureStore.deleteItemAsync(key);
+ },
+};
+
+// Set up adapters
+setStorageAdapter(expoStorageAdapter);
+setDeviceAdapter(yourDeviceAdapter);
+setNetworkAdapter(yourNetworkAdapter);
+
+// Create services
+const authService = createAuthService({
+ baseUrl: process.env.EXPO_PUBLIC_API_URL,
+});
+const tokenManager = createTokenManager(authService);
+
+// Set up fetch interceptor
+setupFetchInterceptor(authService, tokenManager);
+```
+
+## API Reference
+
+### createAuthService(config)
+
+Creates an authentication service instance.
+
+```typescript
+const authService = createAuthService({
+ baseUrl: 'https://api.example.com',
+ storageKeys: {
+ APP_TOKEN: '@auth/appToken',
+ REFRESH_TOKEN: '@auth/refreshToken',
+ USER_EMAIL: '@auth/userEmail',
+ },
+ endpoints: {
+ signIn: '/auth/signin',
+ signUp: '/auth/signup',
+ // ... other endpoints
+ },
+});
+```
+
+### createTokenManager(authService, config?)
+
+Creates a token manager for handling token refresh and state.
+
+```typescript
+const tokenManager = createTokenManager(authService, {
+ maxQueueSize: 50,
+ queueTimeoutMs: 30000,
+ maxRefreshAttempts: 3,
+ refreshCooldownMs: 5000,
+});
+
+// Subscribe to state changes
+const unsubscribe = tokenManager.subscribe((state, token) => {
+ console.log('Token state:', state);
+});
+
+// Get valid token (refreshes if needed)
+const token = await tokenManager.getValidToken();
+```
+
+### JWT Utilities
+
+```typescript
+import {
+ decodeToken,
+ isTokenValidLocally,
+ getUserFromToken,
+ isB2BUser,
+ getB2BInfo,
+} from '@manacore/shared-auth';
+
+const payload = decodeToken(token);
+const isValid = isTokenValidLocally(token);
+const user = getUserFromToken(token);
+const isB2B = isB2BUser(token);
+```
+
+### Adapters
+
+The package uses adapters for platform-specific functionality:
+
+- **StorageAdapter**: For storing tokens securely
+- **DeviceAdapter**: For getting device information
+- **NetworkAdapter**: For checking network connectivity
+
+```typescript
+import {
+ setStorageAdapter,
+ setDeviceAdapter,
+ setNetworkAdapter,
+} from '@manacore/shared-auth';
+
+setStorageAdapter(myStorageAdapter);
+setDeviceAdapter(myDeviceAdapter);
+setNetworkAdapter(myNetworkAdapter);
+```
+
+## Migration from Existing Auth
+
+To migrate from existing auth implementations:
+
+1. Install the package
+2. Set up the adapters for your platform
+3. Replace direct authService calls with the shared service
+4. Update token manager usage
+
+### Before
+
+```typescript
+// memoro/apps/mobile/features/auth/services/authService.ts
+import { authService } from './authService';
+await authService.signIn(email, password);
+```
+
+### After
+
+```typescript
+// Use the shared auth service
+import { authService } from '@/services/auth'; // Your configured instance
+await authService.signIn(email, password);
+```
+
+## Token States
+
+The token manager tracks these states:
+
+- `IDLE`: Initial state
+- `VALID`: Token is valid
+- `REFRESHING`: Token refresh in progress
+- `EXPIRED`: Token has expired
+- `EXPIRED_OFFLINE`: Token expired while offline (preserves auth)
+
+## Contributing
+
+1. Make changes to the source files in `src/`
+2. Run `pnpm run type-check` to validate TypeScript
+3. Run `pnpm run build` to compile
diff --git a/packages/shared-auth/package.json b/packages/shared-auth/package.json
new file mode 100644
index 000000000..9d82107d7
--- /dev/null
+++ b/packages/shared-auth/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@manacore/shared-auth",
+ "version": "0.1.0",
+ "description": "Shared authentication utilities for Manacore apps",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "base64-js": "^1.5.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3"
+ },
+ "peerDependencies": {
+ "react-native": ">=0.70.0"
+ },
+ "peerDependenciesMeta": {
+ "react-native": {
+ "optional": true
+ }
+ },
+ "keywords": [
+ "manacore",
+ "auth",
+ "jwt",
+ "token"
+ ],
+ "license": "MIT"
+}
diff --git a/packages/shared-auth/src/adapters/device.ts b/packages/shared-auth/src/adapters/device.ts
new file mode 100644
index 000000000..12959c463
--- /dev/null
+++ b/packages/shared-auth/src/adapters/device.ts
@@ -0,0 +1,81 @@
+import type { DeviceManagerAdapter, DeviceInfo } from '../types';
+
+let deviceAdapter: DeviceManagerAdapter | null = null;
+
+/**
+ * Set the device manager adapter for the auth service
+ */
+export function setDeviceAdapter(adapter: DeviceManagerAdapter): void {
+ deviceAdapter = adapter;
+}
+
+/**
+ * Get the current device adapter
+ */
+export function getDeviceAdapter(): DeviceManagerAdapter {
+ if (!deviceAdapter) {
+ throw new Error(
+ 'Device adapter not initialized. Call setDeviceAdapter() before using auth services.'
+ );
+ }
+ return deviceAdapter;
+}
+
+/**
+ * Check if device adapter is initialized
+ */
+export function isDeviceInitialized(): boolean {
+ return deviceAdapter !== null;
+}
+
+/**
+ * Create a web-based device manager adapter
+ */
+export function createWebDeviceAdapter(): DeviceManagerAdapter {
+ // Generate a persistent device ID for web
+ const getOrCreateDeviceId = (): string => {
+ const storageKey = '@manacore/deviceId';
+ let deviceId = localStorage.getItem(storageKey);
+ if (!deviceId) {
+ deviceId = crypto.randomUUID();
+ localStorage.setItem(storageKey, deviceId);
+ }
+ return deviceId;
+ };
+
+ return {
+ async getDeviceInfo(): Promise {
+ const userAgent = navigator.userAgent;
+ let deviceName = 'Web Browser';
+ let deviceType = 'web';
+
+ // Try to extract browser name
+ if (userAgent.includes('Chrome')) {
+ deviceName = 'Chrome Browser';
+ } else if (userAgent.includes('Safari')) {
+ deviceName = 'Safari Browser';
+ } else if (userAgent.includes('Firefox')) {
+ deviceName = 'Firefox Browser';
+ } else if (userAgent.includes('Edge')) {
+ deviceName = 'Edge Browser';
+ }
+
+ // Detect device type
+ if (/Mobi|Android/i.test(userAgent)) {
+ deviceType = 'mobile_web';
+ } else if (/Tablet|iPad/i.test(userAgent)) {
+ deviceType = 'tablet_web';
+ }
+
+ return {
+ deviceId: getOrCreateDeviceId(),
+ deviceName,
+ deviceType,
+ platform: 'web',
+ };
+ },
+ async getStoredDeviceId(): Promise {
+ return localStorage.getItem('@manacore/deviceId');
+ },
+ };
+}
diff --git a/packages/shared-auth/src/adapters/network.ts b/packages/shared-auth/src/adapters/network.ts
new file mode 100644
index 000000000..7898655a1
--- /dev/null
+++ b/packages/shared-auth/src/adapters/network.ts
@@ -0,0 +1,55 @@
+import type { NetworkAdapter } from '../types';
+
+let networkAdapter: NetworkAdapter | null = null;
+
+/**
+ * Set the network adapter for the auth service
+ */
+export function setNetworkAdapter(adapter: NetworkAdapter): void {
+ networkAdapter = adapter;
+}
+
+/**
+ * Get the current network adapter
+ */
+export function getNetworkAdapter(): NetworkAdapter | null {
+ return networkAdapter;
+}
+
+/**
+ * Check if device is connected to the network
+ */
+export async function isDeviceConnected(): Promise {
+ if (!networkAdapter) {
+ // Default to true if no adapter is set
+ return true;
+ }
+ return networkAdapter.isDeviceConnected();
+}
+
+/**
+ * Check if device has a stable connection
+ */
+export async function hasStableConnection(): Promise {
+ if (!networkAdapter || !networkAdapter.hasStableConnection) {
+ // Default to basic connectivity check
+ return isDeviceConnected();
+ }
+ return networkAdapter.hasStableConnection();
+}
+
+/**
+ * Create a web-based network adapter
+ */
+export function createWebNetworkAdapter(): NetworkAdapter {
+ return {
+ async isDeviceConnected(): Promise {
+ return navigator.onLine;
+ },
+ async hasStableConnection(): Promise {
+ // For web, we just check online status
+ // More sophisticated checks could be added
+ return navigator.onLine;
+ },
+ };
+}
diff --git a/packages/shared-auth/src/adapters/storage.ts b/packages/shared-auth/src/adapters/storage.ts
new file mode 100644
index 000000000..2b3e415fb
--- /dev/null
+++ b/packages/shared-auth/src/adapters/storage.ts
@@ -0,0 +1,89 @@
+import type { StorageAdapter } from '../types';
+
+/**
+ * Storage adapter that must be implemented by the consuming app.
+ *
+ * For React Native (Expo):
+ * - Use expo-secure-store for sensitive data
+ * - Use @react-native-async-storage/async-storage for non-sensitive data
+ *
+ * For Web:
+ * - Use localStorage or sessionStorage
+ * - Consider using encrypted storage for sensitive data
+ */
+
+let storageAdapter: StorageAdapter | null = null;
+
+/**
+ * Set the storage adapter for the auth service
+ */
+export function setStorageAdapter(adapter: StorageAdapter): void {
+ storageAdapter = adapter;
+}
+
+/**
+ * Get the current storage adapter
+ */
+export function getStorageAdapter(): StorageAdapter {
+ if (!storageAdapter) {
+ throw new Error(
+ 'Storage adapter not initialized. Call setStorageAdapter() before using auth services.'
+ );
+ }
+ return storageAdapter;
+}
+
+/**
+ * Check if storage adapter is initialized
+ */
+export function isStorageInitialized(): boolean {
+ return storageAdapter !== null;
+}
+
+/**
+ * Create a localStorage-based storage adapter (for web)
+ */
+export function createLocalStorageAdapter(): StorageAdapter {
+ return {
+ async getItem(key: string): Promise {
+ const value = localStorage.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 {
+ localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
+ },
+ async removeItem(key: string): Promise {
+ localStorage.removeItem(key);
+ },
+ };
+}
+
+/**
+ * Create an in-memory storage adapter (for testing)
+ */
+export function createMemoryStorageAdapter(): StorageAdapter {
+ const storage = new Map();
+
+ return {
+ async getItem(key: string): Promise {
+ const value = storage.get(key);
+ if (value === undefined) return null;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return value as T;
+ }
+ },
+ async setItem(key: string, value: string): Promise {
+ storage.set(key, typeof value === 'string' ? value : JSON.stringify(value));
+ },
+ async removeItem(key: string): Promise {
+ storage.delete(key);
+ },
+ };
+}
diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts
new file mode 100644
index 000000000..3fb9be057
--- /dev/null
+++ b/packages/shared-auth/src/core/authService.ts
@@ -0,0 +1,546 @@
+import type {
+ AuthServiceConfig,
+ AuthEndpoints,
+ AuthResult,
+ TokenRefreshResult,
+ UserData,
+ StorageKeys,
+ CreditBalance,
+ B2BInfo,
+} from '../types';
+import { getStorageAdapter } from '../adapters/storage';
+import { getDeviceAdapter } from '../adapters/device';
+import {
+ decodeToken,
+ isTokenValidLocally,
+ getUserFromToken,
+ getB2BInfo as getB2BInfoFromToken,
+ shouldDisableRevenueCat as checkRevenueCat,
+ isB2BUser as checkB2BUser,
+ getAppSettings as getAppSettingsFromToken,
+} from './jwtUtils';
+
+/**
+ * Default storage keys
+ */
+const DEFAULT_STORAGE_KEYS: StorageKeys = {
+ APP_TOKEN: '@auth/appToken',
+ REFRESH_TOKEN: '@auth/refreshToken',
+ USER_EMAIL: '@auth/userEmail',
+};
+
+/**
+ * Default API endpoints
+ */
+const DEFAULT_ENDPOINTS: AuthEndpoints = {
+ signIn: '/auth/signin',
+ signUp: '/auth/signup',
+ signOut: '/auth/logout',
+ refresh: '/auth/refresh',
+ validate: '/auth/validate',
+ forgotPassword: '/auth/forgot-password',
+ googleSignIn: '/auth/google-signin',
+ appleSignIn: '/auth/apple-signin',
+ credits: '/auth/credits',
+};
+
+/**
+ * Create an authentication service with the given configuration
+ */
+export function createAuthService(config: AuthServiceConfig) {
+ const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
+ const storageKeys: StorageKeys = { ...DEFAULT_STORAGE_KEYS, ...config.storageKeys };
+ const endpoints: AuthEndpoints = { ...DEFAULT_ENDPOINTS, ...config.endpoints };
+
+ // Callback for token refresh events
+ let onTokenRefreshCallback: ((userData: UserData) => void) | null = null;
+
+ const service = {
+ /**
+ * Sign in with email and password
+ */
+ async signIn(email: string, password: string): Promise {
+ try {
+ const storage = getStorageAdapter();
+ const deviceAdapter = getDeviceAdapter();
+ const deviceInfo = await deviceAdapter.getDeviceInfo();
+
+ const response = await fetch(`${baseUrl}${endpoints.signIn}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password, deviceInfo }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ return service.handleAuthError(response.status, errorData);
+ }
+
+ const { appToken, refreshToken } = await response.json();
+
+ await Promise.all([
+ storage.setItem(storageKeys.APP_TOKEN, appToken),
+ storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
+ storage.setItem(storageKeys.USER_EMAIL, email),
+ ]);
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error signing in:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error during sign in',
+ };
+ }
+ },
+
+ /**
+ * Sign up with email and password
+ */
+ async signUp(email: string, password: string): Promise {
+ try {
+ const storage = getStorageAdapter();
+ const deviceAdapter = getDeviceAdapter();
+ const deviceInfo = await deviceAdapter.getDeviceInfo();
+
+ const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password, deviceInfo }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ if (response.status === 409) {
+ return { success: false, error: 'Email already in use' };
+ } else if (response.status === 400) {
+ return { success: false, error: errorData.message || 'Invalid email or password' };
+ }
+
+ return { success: false, error: errorData.message || 'Sign up failed' };
+ }
+
+ const responseData = await response.json();
+
+ // Check if email verification is required
+ if (responseData.confirmationRequired) {
+ return { success: true, needsVerification: true };
+ }
+
+ const { appToken, refreshToken } = responseData;
+
+ if (appToken && refreshToken) {
+ await Promise.all([
+ storage.setItem(storageKeys.APP_TOKEN, appToken),
+ storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
+ storage.setItem(storageKeys.USER_EMAIL, email),
+ ]);
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error signing up:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error during sign up',
+ };
+ }
+ },
+
+ /**
+ * Sign out the current user
+ */
+ async signOut(): Promise {
+ try {
+ const storage = getStorageAdapter();
+ const refreshToken = await storage.getItem(storageKeys.REFRESH_TOKEN);
+
+ if (refreshToken) {
+ await fetch(`${baseUrl}${endpoints.signOut}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refreshToken }),
+ }).catch((err) => console.error('Error logging out on server:', err));
+ }
+
+ await service.clearAuthStorage();
+ } catch (error) {
+ console.error('Error signing out:', error);
+ }
+ },
+
+ /**
+ * Send password reset email
+ */
+ async forgotPassword(email: string): Promise {
+ try {
+ const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ if (errorData.message?.includes('rate limit')) {
+ return { success: false, error: 'Too many attempts. Please wait before trying again.' };
+ }
+
+ return { success: false, error: errorData.message || 'Password reset failed' };
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error sending password reset email:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error during password reset',
+ };
+ }
+ },
+
+ /**
+ * Refresh the authentication tokens
+ */
+ async refreshTokens(currentRefreshToken: string): Promise {
+ const storage = getStorageAdapter();
+ const deviceAdapter = getDeviceAdapter();
+
+ // Check for device ID mismatch
+ const storedDeviceId = await deviceAdapter.getStoredDeviceId();
+ const deviceInfo = await deviceAdapter.getDeviceInfo();
+
+ if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) {
+ throw new Error('Device ID has changed. Please sign in again.');
+ }
+
+ const response = await fetch(`${baseUrl}${endpoints.refresh}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+
+ if (response.status === 401 && errorData.message === 'Invalid refresh token') {
+ throw new Error('Session expired. Please sign in again.');
+ }
+
+ throw new Error(errorData.message || 'Failed to refresh tokens');
+ }
+
+ const { appToken, refreshToken } = await response.json();
+
+ if (!appToken || !refreshToken) {
+ throw new Error('Invalid response from token refresh - missing tokens');
+ }
+
+ // Store new tokens
+ await storage.setItem(storageKeys.APP_TOKEN, appToken);
+ await storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken);
+
+ // Extract user data from new token
+ const storedEmail = await storage.getItem(storageKeys.USER_EMAIL);
+ const userData = getUserFromToken(appToken, storedEmail || undefined);
+
+ // Notify callback if registered
+ if (userData && onTokenRefreshCallback) {
+ onTokenRefreshCallback(userData);
+ }
+
+ return { appToken, refreshToken, userData };
+ },
+
+ /**
+ * Sign in with Google
+ */
+ async signInWithGoogle(idToken: string): Promise {
+ return service.signInWithSocial(idToken, endpoints.googleSignIn);
+ },
+
+ /**
+ * Sign in with Apple
+ */
+ async signInWithApple(identityToken: string): Promise {
+ return service.signInWithSocial(identityToken, endpoints.appleSignIn);
+ },
+
+ /**
+ * Internal: Sign in with social provider
+ */
+ async signInWithSocial(token: string, endpoint: string): Promise {
+ try {
+ const storage = getStorageAdapter();
+ const deviceAdapter = getDeviceAdapter();
+ const deviceInfo = await deviceAdapter.getDeviceInfo();
+
+ const response = await fetch(`${baseUrl}${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, deviceInfo }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ return { success: false, error: errorData.message || 'Social sign in failed' };
+ }
+
+ const responseData = await response.json();
+ const { appToken, refreshToken } = responseData;
+
+ // Extract email from response or token
+ let email = responseData.email;
+ if (!email && appToken) {
+ const userData = getUserFromToken(appToken);
+ email = userData?.email;
+ }
+
+ // Store tokens
+ const storagePromises = [
+ storage.setItem(storageKeys.APP_TOKEN, appToken),
+ storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
+ ];
+
+ if (email) {
+ storagePromises.push(storage.setItem(storageKeys.USER_EMAIL, email));
+ }
+
+ await Promise.all(storagePromises);
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error with social sign in:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error during social sign in',
+ };
+ }
+ },
+
+ /**
+ * Get the current app token
+ */
+ async getAppToken(): Promise {
+ try {
+ const storage = getStorageAdapter();
+ return await storage.getItem(storageKeys.APP_TOKEN);
+ } catch (error) {
+ console.error('Error getting app token:', error);
+ return null;
+ }
+ },
+
+ /**
+ * Get the current refresh token
+ */
+ async getRefreshToken(): Promise {
+ try {
+ const storage = getStorageAdapter();
+ return await storage.getItem(storageKeys.REFRESH_TOKEN);
+ } catch (error) {
+ console.debug('Error getting refresh token:', error);
+ return null;
+ }
+ },
+
+ /**
+ * Update stored tokens
+ */
+ async updateTokens(appToken: string, refreshToken: string): Promise {
+ const storage = getStorageAdapter();
+ await Promise.all([
+ storage.setItem(storageKeys.APP_TOKEN, appToken),
+ storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
+ ]);
+
+ // Notify callback
+ const storedEmail = await storage.getItem(storageKeys.USER_EMAIL);
+ const userData = getUserFromToken(appToken, storedEmail || undefined);
+ if (userData && onTokenRefreshCallback) {
+ onTokenRefreshCallback(userData);
+ }
+ },
+
+ /**
+ * Get user from current token
+ */
+ async getUserFromToken(): Promise {
+ const storage = getStorageAdapter();
+ const appToken = await storage.getItem(storageKeys.APP_TOKEN);
+ if (!appToken) return null;
+
+ const storedEmail = await storage.getItem(storageKeys.USER_EMAIL);
+ return getUserFromToken(appToken, storedEmail || undefined);
+ },
+
+ /**
+ * Clear all authentication data
+ */
+ async clearAuthStorage(): Promise {
+ const storage = getStorageAdapter();
+ await Promise.all(
+ Object.values(storageKeys).map((key) => storage.removeItem(key))
+ );
+ },
+
+ /**
+ * Check if user is authenticated
+ */
+ async isAuthenticated(): Promise {
+ const appToken = await service.getAppToken();
+ if (!appToken) return false;
+ return isTokenValidLocally(appToken);
+ },
+
+ /**
+ * Check if token is valid locally
+ */
+ isTokenValidLocally(token: string): boolean {
+ return isTokenValidLocally(token);
+ },
+
+ /**
+ * Decode token
+ */
+ decodeToken(token: string) {
+ return decodeToken(token);
+ },
+
+ /**
+ * Get user credits
+ */
+ async getUserCredits(): Promise {
+ try {
+ const appToken = await service.getAppToken();
+ if (!appToken) return null;
+
+ const response = await fetch(`${baseUrl}${endpoints.credits}`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${appToken}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch user credits');
+ }
+
+ const data = await response.json();
+ return {
+ credits: data.credits || 0,
+ maxCreditLimit: data.max_credit_limit || 1000,
+ userId: data.id || 'unknown',
+ };
+ } catch (error) {
+ console.error('Error fetching user credits:', error);
+ return null;
+ }
+ },
+
+ /**
+ * Check if user is B2B
+ */
+ async isB2BUser(): Promise {
+ const appToken = await service.getAppToken();
+ if (!appToken) return false;
+ return checkB2BUser(appToken);
+ },
+
+ /**
+ * Get B2B information
+ */
+ async getB2BInfo(): Promise {
+ const appToken = await service.getAppToken();
+ if (!appToken) return null;
+ return getB2BInfoFromToken(appToken);
+ },
+
+ /**
+ * Check if RevenueCat should be disabled
+ */
+ async shouldDisableRevenueCat(): Promise {
+ const appToken = await service.getAppToken();
+ if (!appToken) return false;
+ return checkRevenueCat(appToken);
+ },
+
+ /**
+ * Get app settings from token
+ */
+ async getAppSettings(): Promise | null> {
+ const appToken = await service.getAppToken();
+ if (!appToken) return null;
+ return getAppSettingsFromToken(appToken);
+ },
+
+ /**
+ * Set callback for token refresh events
+ */
+ set onTokenRefresh(callback: ((userData: UserData) => void) | null) {
+ onTokenRefreshCallback = callback;
+ },
+
+ /**
+ * Get callback for token refresh events
+ */
+ get onTokenRefresh(): ((userData: UserData) => void) | null {
+ return onTokenRefreshCallback;
+ },
+
+ /**
+ * Handle authentication errors
+ */
+ handleAuthError(status: number, errorData: Record): AuthResult {
+ if (status === 401) {
+ const isFirebaseUserNeedsReset =
+ String(errorData.message).includes('Firebase user detected') ||
+ String(errorData.message).includes('password reset required') ||
+ errorData.code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED';
+
+ if (isFirebaseUserNeedsReset) {
+ return { success: false, error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED' };
+ }
+
+ const isEmailNotConfirmed =
+ String(errorData.message).includes('Email not confirmed') ||
+ String(errorData.message).includes('Email not verified') ||
+ errorData.code === 'EMAIL_NOT_VERIFIED';
+
+ if (isEmailNotConfirmed) {
+ return { success: false, error: 'EMAIL_NOT_VERIFIED' };
+ }
+
+ return { success: false, error: 'INVALID_CREDENTIALS' };
+ } else if (status === 403) {
+ return { success: false, error: 'EMAIL_NOT_VERIFIED' };
+ }
+
+ return { success: false, error: String(errorData.message) || 'Authentication failed' };
+ },
+
+ /**
+ * Get the base URL
+ */
+ getBaseUrl(): string {
+ return baseUrl;
+ },
+
+ /**
+ * Get storage keys
+ */
+ getStorageKeys(): StorageKeys {
+ return storageKeys;
+ },
+ };
+
+ return service;
+}
+
+/**
+ * Type for the auth service instance
+ */
+export type AuthService = ReturnType;
diff --git a/packages/shared-auth/src/core/jwtUtils.ts b/packages/shared-auth/src/core/jwtUtils.ts
new file mode 100644
index 000000000..e44f957bc
--- /dev/null
+++ b/packages/shared-auth/src/core/jwtUtils.ts
@@ -0,0 +1,160 @@
+import type { DecodedToken, UserData } from '../types';
+
+/**
+ * Decode a JWT token payload
+ */
+export function decodeToken(token: string): DecodedToken | null {
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) {
+ return null;
+ }
+
+ const base64Url = parts[1];
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+
+ // Add padding if needed
+ const padding = base64.length % 4;
+ const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
+
+ // Decode base64 - atob is available in browsers, Node.js 16+, and React Native
+ const payload: DecodedToken = JSON.parse(atob(paddedBase64));
+
+ return payload;
+ } catch (error) {
+ console.error('Error decoding JWT token:', error);
+ return null;
+ }
+}
+
+/**
+ * Check if a token is valid locally (not expired)
+ */
+export function isTokenValidLocally(token: string, bufferSeconds: number = 10): boolean {
+ try {
+ const payload = decodeToken(token);
+ if (!payload || !payload.exp) {
+ return false;
+ }
+
+ const bufferTime = bufferSeconds * 1000;
+ const expiryTime = payload.exp * 1000;
+ const currentTime = Date.now();
+
+ return currentTime < expiryTime - bufferTime;
+ } catch (error) {
+ console.debug('Error validating token locally:', error);
+ return false;
+ }
+}
+
+/**
+ * Check if a token is expired
+ */
+export function isTokenExpired(token: string): boolean {
+ return !isTokenValidLocally(token, 0);
+}
+
+/**
+ * Extract user data from a JWT token
+ */
+export function getUserFromToken(token: string, storedEmail?: string): UserData | null {
+ try {
+ const payload = decodeToken(token);
+ if (!payload) {
+ return null;
+ }
+
+ // Get email from various sources
+ let email = payload.email || '';
+ if (!email && payload.user_metadata?.email) {
+ email = payload.user_metadata.email;
+ }
+ if (!email && storedEmail) {
+ email = storedEmail;
+ }
+
+ return {
+ id: payload.sub,
+ email: email || 'user@example.com',
+ role: payload.role || 'user',
+ };
+ } catch (error) {
+ console.error('Error extracting user from token:', error);
+ return null;
+ }
+}
+
+/**
+ * Get token expiration time in milliseconds
+ */
+export function getTokenExpirationTime(token: string): number | null {
+ const payload = decodeToken(token);
+ if (!payload || !payload.exp) {
+ return null;
+ }
+ return payload.exp * 1000;
+}
+
+/**
+ * Get time until token expiration in milliseconds
+ */
+export function getTimeUntilExpiration(token: string): number {
+ const expirationTime = getTokenExpirationTime(token);
+ if (!expirationTime) {
+ return 0;
+ }
+ return Math.max(0, expirationTime - Date.now());
+}
+
+/**
+ * Check if user is B2B based on JWT claims
+ */
+export function isB2BUser(token: string): boolean {
+ const payload = decodeToken(token);
+ if (!payload) {
+ return false;
+ }
+
+ // Handle different types for is_b2b
+ return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
+}
+
+/**
+ * Get B2B information from JWT claims
+ */
+export function getB2BInfo(token: string): {
+ disableRevenueCat: boolean;
+ organizationId?: string;
+ plan?: string;
+ role?: string;
+} | null {
+ const payload = decodeToken(token);
+ if (!payload?.app_settings?.b2b) {
+ return null;
+ }
+
+ const b2bSettings = payload.app_settings.b2b;
+ return {
+ disableRevenueCat: !!b2bSettings.disableRevenueCat,
+ organizationId: b2bSettings.organizationId,
+ plan: b2bSettings.plan,
+ role: b2bSettings.role,
+ };
+}
+
+/**
+ * Check if RevenueCat should be disabled for this token
+ */
+export function shouldDisableRevenueCat(token: string): boolean {
+ const b2bInfo = getB2BInfo(token);
+ return b2bInfo?.disableRevenueCat ?? false;
+}
+
+/**
+ * Get app settings from JWT claims
+ */
+export function getAppSettings(token: string): Record | null {
+ const payload = decodeToken(token);
+ return payload?.app_settings || null;
+}
diff --git a/packages/shared-auth/src/core/tokenManager.ts b/packages/shared-auth/src/core/tokenManager.ts
new file mode 100644
index 000000000..417078b20
--- /dev/null
+++ b/packages/shared-auth/src/core/tokenManager.ts
@@ -0,0 +1,464 @@
+import type {
+ TokenState,
+ TokenStateObserver,
+ QueuedRequest,
+ InternalTokenRefreshResult,
+} from '../types';
+import { TokenState as TokenStateEnum } from '../types';
+import { isDeviceConnected, hasStableConnection } from '../adapters/network';
+import type { AuthService } from './authService';
+
+/**
+ * Configuration for the token manager
+ */
+export interface TokenManagerConfig {
+ maxQueueSize?: number;
+ queueTimeoutMs?: number;
+ maxRefreshAttempts?: number;
+ refreshCooldownMs?: number;
+}
+
+/**
+ * Create a token manager instance
+ */
+export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
+ // Configuration
+ const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
+ const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
+ const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
+ const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
+
+ // State
+ let state: TokenState = TokenStateEnum.IDLE;
+ let refreshPromise: Promise | null = null;
+ let requestQueue: QueuedRequest[] = [];
+ const observers = new Set();
+ let refreshAttempts = 0;
+ let lastRefreshTime = 0;
+
+ // Internal functions
+ function notifyObservers(newState: TokenState, token?: string): void {
+ observers.forEach((observer) => {
+ try {
+ observer(newState, token);
+ } catch (error) {
+ console.debug('Error in token state observer:', error);
+ }
+ });
+ }
+
+ function setState(newState: TokenState, token?: string): void {
+ if (state !== newState) {
+ console.debug(`TokenManager: State transition ${state} -> ${newState}`);
+ state = newState;
+ notifyObservers(newState, token);
+ }
+ }
+
+ function removeFromQueue(requestId: string): void {
+ const index = requestQueue.findIndex((item) => item.id === requestId);
+ if (index !== -1) {
+ requestQueue.splice(index, 1);
+ }
+ }
+
+ function isRecoverableError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+
+ const networkErrors = [
+ 'network', 'Network', 'fetch', 'connection', 'timeout',
+ 'Failed to fetch', 'NetworkError', 'TypeError', 'ERR_NETWORK',
+ 'ERR_INTERNET_DISCONNECTED', 'ECONNREFUSED', 'ENOTFOUND',
+ 'ETIMEDOUT', 'Unable to resolve host', 'Request failed',
+ ];
+
+ const authErrors = [
+ '401', '403', 'Unauthorized', 'Forbidden', 'Invalid token',
+ 'Token expired', 'jwt expired', 'jwt malformed',
+ ];
+
+ const errorString = `${error.message} ${error.name}`.toLowerCase();
+
+ const isNetworkError = networkErrors.some((keyword) =>
+ errorString.includes(keyword.toLowerCase())
+ );
+
+ const isAuthError = authErrors.some((keyword) =>
+ errorString.includes(keyword.toLowerCase())
+ );
+
+ return isNetworkError && !isAuthError;
+ }
+
+ async function handleRefreshFailure(): Promise {
+ console.debug('TokenManager: Handling permanent refresh failure');
+ try {
+ await authService.clearAuthStorage();
+ setState(TokenStateEnum.EXPIRED);
+ } catch (error) {
+ console.debug('Error in handleRefreshFailure:', error);
+ }
+ }
+
+ async function performTokenRefresh(): Promise {
+ try {
+ console.debug('TokenManager: Starting token refresh');
+
+ const isOnline = await isDeviceConnected();
+ if (!isOnline) {
+ console.debug('TokenManager: Device offline, skipping refresh');
+ const currentToken = await authService.getAppToken();
+ if (currentToken) {
+ setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
+ }
+ return { success: false, error: 'offline', shouldPreserveAuth: true };
+ }
+
+ const isStable = await hasStableConnection();
+ if (!isStable) {
+ console.debug('TokenManager: Connection not stable yet, will retry');
+ return { success: false, error: 'unstable_connection' };
+ }
+
+ const refreshToken = await authService.getRefreshToken();
+ if (!refreshToken) {
+ throw new Error('No refresh token available');
+ }
+
+ const refreshResult = await authService.refreshTokens(refreshToken);
+ const { appToken } = refreshResult;
+
+ console.debug('TokenManager: Token refresh successful');
+ return { success: true, token: appToken };
+ } catch (error) {
+ console.debug('TokenManager: Token refresh failed:', error);
+
+ const isRecoverable = isRecoverableError(error);
+ if (!isRecoverable) {
+ await handleRefreshFailure();
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown refresh error',
+ };
+ }
+ }
+
+ async function performTokenRefreshWithRetry(): Promise {
+ const retryDelays = [0, 1000, 2000, 5000];
+ let lastError: unknown = null;
+
+ for (let attempt = 0; attempt < retryDelays.length; attempt++) {
+ try {
+ if (retryDelays[attempt] > 0) {
+ console.debug(
+ `TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
+ );
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
+ }
+
+ const result = await performTokenRefresh();
+
+ if (result.success) {
+ return result;
+ }
+
+ // Non-retryable errors
+ if (
+ result.error === 'invalid_token' ||
+ result.error === 'token_expired' ||
+ result.error?.includes('Device ID has changed')
+ ) {
+ return result;
+ }
+
+ if (result.error === 'offline') {
+ return { success: false, error: 'offline', shouldPreserveAuth: true };
+ }
+
+ if (result.error === 'unstable_connection') {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+
+ lastError = new Error(result.error || 'Token refresh failed');
+
+ if (attempt === retryDelays.length - 1) break;
+ } catch (error) {
+ lastError = error;
+ const isRecoverable = isRecoverableError(error);
+
+ if (!isRecoverable || attempt === retryDelays.length - 1) {
+ break;
+ }
+ }
+ }
+
+ return {
+ success: false,
+ error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
+ };
+ }
+
+ async function processQueuedRequests(token: string): Promise {
+ console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
+
+ const requests = [...requestQueue];
+ requestQueue = [];
+
+ for (const request of requests) {
+ try {
+ const response = await retryRequestWithToken(request.input, request.init, token);
+ request.resolve(response);
+ } catch (error) {
+ request.reject(error);
+ }
+ }
+ }
+
+ async function rejectQueuedRequests(error: string): Promise {
+ console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
+
+ const requests = [...requestQueue];
+ requestQueue = [];
+
+ for (const request of requests) {
+ request.reject(new Error(error));
+ }
+ }
+
+ async function retryRequestWithToken(
+ input: RequestInfo | URL,
+ init: RequestInit | undefined,
+ token: string
+ ): Promise {
+ const headers = new Headers(init?.headers || {});
+ headers.set('Authorization', `Bearer ${token}`);
+
+ return fetch(input, {
+ ...init,
+ headers,
+ });
+ }
+
+ // Public API
+ const manager = {
+ /**
+ * Subscribe to token state changes
+ */
+ subscribe(observer: TokenStateObserver): () => void {
+ observers.add(observer);
+ return () => observers.delete(observer);
+ },
+
+ /**
+ * Get current token state
+ */
+ getState(): TokenState {
+ return state;
+ },
+
+ /**
+ * Get a valid token, refreshing if necessary
+ */
+ async getValidToken(): Promise {
+ const currentToken = await authService.getAppToken();
+
+ if (currentToken && authService.isTokenValidLocally(currentToken)) {
+ setState(TokenStateEnum.VALID, currentToken);
+ return currentToken;
+ }
+
+ if (!currentToken) {
+ console.debug('TokenManager: No token available, skipping refresh');
+ setState(TokenStateEnum.EXPIRED);
+ return null;
+ }
+
+ const isOnline = await isDeviceConnected();
+ if (!isOnline) {
+ console.debug('TokenManager: Token expired while offline');
+ setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
+ return currentToken;
+ }
+
+ const refreshResult = await manager.refreshToken();
+ if (refreshResult.success && refreshResult.token) {
+ return refreshResult.token;
+ }
+
+ if (refreshResult.shouldPreserveAuth) {
+ setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
+ return currentToken;
+ }
+
+ return null;
+ },
+
+ /**
+ * Handle 401 response
+ */
+ async handle401Response(
+ input: RequestInfo | URL,
+ init?: RequestInit
+ ): Promise {
+ if (state === TokenStateEnum.REFRESHING && refreshPromise) {
+ return manager.queueRequest(input, init);
+ }
+
+ const refreshResult = await manager.refreshToken();
+
+ if (refreshResult.success && refreshResult.token) {
+ return retryRequestWithToken(input, init, refreshResult.token);
+ }
+
+ throw new Error(refreshResult.error || 'Token refresh failed');
+ },
+
+ /**
+ * Queue a request during token refresh
+ */
+ async queueRequest(
+ input: RequestInfo | URL,
+ init?: RequestInit
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ if (requestQueue.length >= MAX_QUEUE_SIZE) {
+ reject(new Error('Request queue full'));
+ return;
+ }
+
+ const queueItem: QueuedRequest = {
+ id: Math.random().toString(36).substring(2, 11),
+ input,
+ init,
+ resolve,
+ reject,
+ timestamp: Date.now(),
+ };
+
+ requestQueue.push(queueItem);
+
+ setTimeout(() => {
+ removeFromQueue(queueItem.id);
+ reject(new Error('Queued request timeout'));
+ }, QUEUE_TIMEOUT_MS);
+ });
+ },
+
+ /**
+ * Refresh the authentication token
+ */
+ async refreshToken(): Promise {
+ const now = Date.now();
+ if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
+ return { success: false, error: 'Refresh cooldown active' };
+ }
+
+ if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
+ await handleRefreshFailure();
+ return { success: false, error: 'Max refresh attempts reached' };
+ }
+
+ if (refreshPromise) {
+ return refreshPromise;
+ }
+
+ setState(TokenStateEnum.REFRESHING);
+ lastRefreshTime = now;
+
+ refreshPromise = performTokenRefreshWithRetry();
+
+ try {
+ const result = await refreshPromise;
+
+ if (result.success) {
+ refreshAttempts = 0;
+ setState(TokenStateEnum.VALID, result.token);
+ await processQueuedRequests(result.token!);
+ } else {
+ refreshAttempts++;
+ setState(TokenStateEnum.EXPIRED);
+ await rejectQueuedRequests(result.error || 'Token refresh failed');
+ }
+
+ return result;
+ } finally {
+ refreshPromise = null;
+ }
+ },
+
+ /**
+ * Reset the token manager state
+ */
+ reset(): void {
+ state = TokenStateEnum.IDLE;
+ refreshPromise = null;
+ refreshAttempts = 0;
+ lastRefreshTime = 0;
+
+ const requests = [...requestQueue];
+ requestQueue = [];
+
+ for (const request of requests) {
+ request.reject(new Error('Token manager reset'));
+ }
+ },
+
+ /**
+ * Clear tokens and reset state
+ */
+ async clearTokens(): Promise {
+ try {
+ await authService.clearAuthStorage();
+ manager.reset();
+ } catch (error) {
+ console.debug('Error clearing tokens:', error);
+ manager.reset();
+ }
+ },
+
+ /**
+ * Get queue status for debugging
+ */
+ getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
+ return {
+ size: requestQueue.length,
+ state,
+ refreshAttempts,
+ };
+ },
+
+ /**
+ * Check initial token state
+ */
+ async checkInitialState(): Promise {
+ try {
+ const token = await authService.getAppToken();
+ if (!token) {
+ setState(TokenStateEnum.EXPIRED);
+ return;
+ }
+
+ if (authService.isTokenValidLocally(token)) {
+ setState(TokenStateEnum.VALID, token);
+ } else {
+ setState(TokenStateEnum.EXPIRED);
+ }
+ } catch (error) {
+ console.debug('Error checking initial token state:', error);
+ setState(TokenStateEnum.EXPIRED);
+ }
+ },
+ };
+
+ // Initialize
+ manager.checkInitialState();
+
+ return manager;
+}
+
+/**
+ * Type for the token manager instance
+ */
+export type TokenManager = ReturnType;
diff --git a/packages/shared-auth/src/index.ts b/packages/shared-auth/src/index.ts
new file mode 100644
index 000000000..faeaf60d4
--- /dev/null
+++ b/packages/shared-auth/src/index.ts
@@ -0,0 +1,99 @@
+// Types
+export * from './types';
+
+// Core utilities
+import { createAuthService as _createAuthService } from './core/authService';
+export { createAuthService } from './core/authService';
+export type { AuthService } from './core/authService';
+
+import { createTokenManager as _createTokenManager } from './core/tokenManager';
+export { createTokenManager } from './core/tokenManager';
+export type { TokenManager, TokenManagerConfig } from './core/tokenManager';
+
+export {
+ decodeToken,
+ isTokenValidLocally,
+ isTokenExpired,
+ getUserFromToken,
+ getTokenExpirationTime,
+ getTimeUntilExpiration,
+ isB2BUser,
+ getB2BInfo,
+ shouldDisableRevenueCat,
+ getAppSettings,
+} from './core/jwtUtils';
+
+// Storage adapter
+import {
+ setStorageAdapter as _setStorageAdapter,
+ createLocalStorageAdapter as _createLocalStorageAdapter,
+} from './adapters/storage';
+export {
+ setStorageAdapter,
+ getStorageAdapter,
+ isStorageInitialized,
+ createLocalStorageAdapter,
+ createMemoryStorageAdapter,
+} from './adapters/storage';
+
+// Device adapter
+import {
+ setDeviceAdapter as _setDeviceAdapter,
+ createWebDeviceAdapter as _createWebDeviceAdapter,
+} from './adapters/device';
+export {
+ setDeviceAdapter,
+ getDeviceAdapter,
+ isDeviceInitialized,
+ createWebDeviceAdapter,
+} from './adapters/device';
+
+// Network adapter
+import {
+ setNetworkAdapter as _setNetworkAdapter,
+ createWebNetworkAdapter as _createWebNetworkAdapter,
+} from './adapters/network';
+export {
+ setNetworkAdapter,
+ getNetworkAdapter,
+ isDeviceConnected,
+ hasStableConnection,
+ createWebNetworkAdapter,
+} from './adapters/network';
+
+// Fetch interceptor
+import { setupFetchInterceptor as _setupFetchInterceptor } from './interceptors/fetchInterceptor';
+export {
+ setupFetchInterceptor,
+ setupTokenObservers,
+ getInterceptorStatus,
+} from './interceptors/fetchInterceptor';
+export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
+
+/**
+ * Initialize auth service with all adapters for web
+ *
+ * @example
+ * ```typescript
+ * import { initializeWebAuth } from '@manacore/shared-auth';
+ *
+ * const { authService, tokenManager } = initializeWebAuth({
+ * baseUrl: 'https://api.example.com',
+ * });
+ * ```
+ */
+export function initializeWebAuth(config: { baseUrl: string; storageKeys?: Partial }) {
+ // Set up adapters
+ _setStorageAdapter(_createLocalStorageAdapter());
+ _setDeviceAdapter(_createWebDeviceAdapter());
+ _setNetworkAdapter(_createWebNetworkAdapter());
+
+ // Create services
+ const authService = _createAuthService(config);
+ const tokenManager = _createTokenManager(authService);
+
+ // Set up interceptor
+ _setupFetchInterceptor(authService, tokenManager);
+
+ return { authService, tokenManager };
+}
diff --git a/packages/shared-auth/src/interceptors/fetchInterceptor.ts b/packages/shared-auth/src/interceptors/fetchInterceptor.ts
new file mode 100644
index 000000000..d6fee6fe5
--- /dev/null
+++ b/packages/shared-auth/src/interceptors/fetchInterceptor.ts
@@ -0,0 +1,220 @@
+import type { TokenManager } from '../core/tokenManager';
+import type { AuthService } from '../core/authService';
+import { TokenState } from '../types';
+
+/**
+ * Configuration for the fetch interceptor
+ */
+export interface FetchInterceptorConfig {
+ /**
+ * Patterns to skip (won't be intercepted)
+ */
+ skipPatterns?: string[];
+ /**
+ * Backend URL to match (only intercept requests to this URL)
+ */
+ backendUrl?: string;
+}
+
+/**
+ * Default patterns to skip
+ */
+const DEFAULT_SKIP_PATTERNS = [
+ // Auth endpoints
+ '/auth/signin',
+ '/auth/signup',
+ '/auth/refresh',
+ '/auth/forgot-password',
+ '/auth/reset-password',
+ '/auth/verify',
+ '/auth/logout',
+ // Public endpoints
+ '/health',
+ '/ping',
+ '/status',
+ '/version',
+ '/public/',
+ // Storage endpoints
+ '.supabase.co/storage/',
+ '/storage/v1/',
+ // External APIs
+ 'googleapis.com',
+ 'firebase.com',
+ 'firebaseapp.com',
+ 'replicate.com',
+ 'openai.com',
+ 'anthropic.com',
+];
+
+/**
+ * Setup a global fetch interceptor for automatic token handling
+ */
+export function setupFetchInterceptor(
+ authService: AuthService,
+ tokenManager: TokenManager,
+ config?: FetchInterceptorConfig
+): void {
+ if (typeof globalThis === 'undefined' || !globalThis.fetch) {
+ console.warn('FetchInterceptor: globalThis.fetch not available');
+ return;
+ }
+
+ const originalFetch = globalThis.fetch;
+ const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])];
+ const backendUrl = config?.backendUrl || authService.getBaseUrl();
+
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = extractUrl(input);
+
+ // Skip intercepting if URL doesn't match criteria
+ if (shouldSkipInterception(url, skipPatterns, backendUrl)) {
+ return originalFetch(input, init);
+ }
+
+ console.debug('Fetch interceptor: Intercepting URL:', url);
+
+ try {
+ // Make request with current token
+ const response = await makeRequestWithToken(originalFetch, authService, input, init);
+
+ // Handle 401 responses
+ if (response.status === 401) {
+ const responseData = await response.clone().json().catch(() => ({}));
+ console.debug('Fetch interceptor: Received 401 response:', responseData);
+
+ if (isTokenExpiredResponse(responseData)) {
+ console.debug('Fetch interceptor: Token expired, delegating to TokenManager');
+ return tokenManager.handle401Response(input, init);
+ }
+ }
+
+ return response;
+ } catch (error) {
+ console.debug('Error in global fetch interceptor:', error);
+ return originalFetch(input, init);
+ }
+ }) as typeof fetch;
+}
+
+/**
+ * Setup token state observers for integrations (e.g., Supabase)
+ */
+export function setupTokenObservers(
+ tokenManager: TokenManager,
+ onValid?: (token: string) => void | Promise,
+ onExpired?: () => void | Promise
+): () => void {
+ return tokenManager.subscribe(async (state, token) => {
+ try {
+ if (state === TokenState.VALID && token && onValid) {
+ await onValid(token);
+ } else if (state === TokenState.EXPIRED && onExpired) {
+ await onExpired();
+ }
+ } catch (error) {
+ console.debug('Error in token observer:', error);
+ }
+ });
+}
+
+/**
+ * Extract URL from various input types
+ */
+function extractUrl(input: RequestInfo | URL): string {
+ if (typeof input === 'string') {
+ return input;
+ } else if (input instanceof URL) {
+ return input.toString();
+ } else if (input instanceof Request) {
+ return input.url;
+ }
+ return '';
+}
+
+/**
+ * Check if request should skip interception
+ */
+function shouldSkipInterception(
+ url: string,
+ skipPatterns: string[],
+ backendUrl: string
+): boolean {
+ if (!url) return true;
+
+ const lowerUrl = url.toLowerCase();
+
+ // Check skip patterns
+ if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) {
+ return true;
+ }
+
+ // Check if URL matches backend
+ const backendDomain = backendUrl
+ .replace(/https?:\/\//, '')
+ .replace(/:\d+$/, '')
+ .toLowerCase();
+
+ if (!lowerUrl.includes(backendDomain)) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Make a request with the current token
+ */
+async function makeRequestWithToken(
+ originalFetch: typeof fetch,
+ authService: AuthService,
+ input: RequestInfo | URL,
+ init?: RequestInit
+): Promise {
+ const token = await authService.getAppToken();
+
+ const requestInit: RequestInit = {
+ method: init?.method || 'GET',
+ ...init,
+ };
+
+ if (token) {
+ const headers = new Headers(requestInit.headers || {});
+ headers.set('Authorization', `Bearer ${token}`);
+ requestInit.headers = headers;
+ }
+
+ return originalFetch(input, requestInit);
+}
+
+/**
+ * Check if response indicates token expiration
+ */
+function isTokenExpiredResponse(responseData: Record): boolean {
+ const error = responseData.error as Record | undefined;
+ const errorMessage = String(error?.message || responseData.message || responseData.error || '');
+ const errorCode = String(responseData.code || error?.code || '');
+
+ return (
+ errorMessage === 'JWT expired' ||
+ errorCode === 'PGRST301' ||
+ errorMessage === 'Unauthorized'
+ );
+}
+
+/**
+ * Get interceptor status for debugging
+ */
+export function getInterceptorStatus(
+ authService: AuthService,
+ tokenManager: TokenManager
+): {
+ isSetup: boolean;
+ backendUrl: string;
+ tokenManager: { size: number; state: string; refreshAttempts: number };
+} {
+ return {
+ isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined,
+ backendUrl: authService.getBaseUrl(),
+ tokenManager: tokenManager.getQueueStatus(),
+ };
+}
diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts
new file mode 100644
index 000000000..a5def6020
--- /dev/null
+++ b/packages/shared-auth/src/types/index.ts
@@ -0,0 +1,178 @@
+/**
+ * Storage keys for authentication data
+ */
+export interface StorageKeys {
+ APP_TOKEN: string;
+ REFRESH_TOKEN: string;
+ USER_EMAIL: string;
+}
+
+/**
+ * Device information for multi-device support
+ */
+export interface DeviceInfo {
+ deviceId: string;
+ deviceName: string;
+ deviceType: string;
+ platform?: string;
+}
+
+/**
+ * Decoded JWT token payload
+ */
+export interface DecodedToken {
+ sub: string;
+ email?: string;
+ role?: string;
+ exp: number;
+ iat: number;
+ aud?: string;
+ app_id?: string;
+ is_b2b?: boolean | string | number;
+ subscription_plan_id?: string;
+ user_metadata?: {
+ email?: string;
+ };
+ app_settings?: {
+ b2b?: {
+ disableRevenueCat?: boolean;
+ organizationId?: string;
+ plan?: string;
+ role?: string;
+ };
+ };
+}
+
+/**
+ * User data extracted from token
+ */
+export interface UserData {
+ id: string;
+ email: string;
+ role: string;
+}
+
+/**
+ * Authentication result from sign in/up
+ */
+export interface AuthResult {
+ success: boolean;
+ error?: string;
+ needsVerification?: boolean;
+}
+
+/**
+ * Token refresh result
+ */
+export interface TokenRefreshResult {
+ appToken: string;
+ refreshToken: string;
+ userData?: UserData | null;
+}
+
+/**
+ * Token state for the token manager
+ */
+export enum TokenState {
+ IDLE = 'idle',
+ REFRESHING = 'refreshing',
+ EXPIRED = 'expired',
+ EXPIRED_OFFLINE = 'expired_offline',
+ VALID = 'valid',
+}
+
+/**
+ * Token state observer callback
+ */
+export type TokenStateObserver = (state: TokenState, token?: string) => void;
+
+/**
+ * Queued request item during token refresh
+ */
+export interface QueuedRequest {
+ id: string;
+ input: RequestInfo | URL;
+ init?: RequestInit;
+ resolve: (value: Response) => void;
+ reject: (reason?: unknown) => void;
+ timestamp: number;
+}
+
+/**
+ * Internal token refresh result
+ */
+export interface InternalTokenRefreshResult {
+ success: boolean;
+ token?: string;
+ error?: string;
+ shouldPreserveAuth?: boolean;
+ shouldRetry?: boolean;
+}
+
+/**
+ * Configuration for the auth service
+ */
+export interface AuthServiceConfig {
+ baseUrl: string;
+ storageKeys?: Partial;
+ endpoints?: Partial;
+}
+
+/**
+ * Auth API endpoints
+ */
+export interface AuthEndpoints {
+ signIn: string;
+ signUp: string;
+ signOut: string;
+ refresh: string;
+ validate: string;
+ forgotPassword: string;
+ googleSignIn: string;
+ appleSignIn: string;
+ credits: string;
+}
+
+/**
+ * Storage adapter interface
+ */
+export interface StorageAdapter {
+ getItem(key: string): Promise;
+ setItem(key: string, value: string): Promise;
+ removeItem(key: string): Promise;
+}
+
+/**
+ * Device manager adapter interface
+ */
+export interface DeviceManagerAdapter {
+ getDeviceInfo(): Promise;
+ getStoredDeviceId(): Promise;
+}
+
+/**
+ * Network utilities adapter interface
+ */
+export interface NetworkAdapter {
+ isDeviceConnected(): Promise;
+ hasStableConnection?(): Promise;
+}
+
+/**
+ * Credit balance response
+ */
+export interface CreditBalance {
+ credits: number;
+ maxCreditLimit: number;
+ userId: string;
+}
+
+/**
+ * B2B information from JWT claims
+ */
+export interface B2BInfo {
+ disableRevenueCat: boolean;
+ organizationId?: string;
+ plan?: string;
+ role?: string;
+}
diff --git a/packages/shared-auth/tsconfig.json b/packages/shared-auth/tsconfig.json
new file mode 100644
index 000000000..a0d235f9f
--- /dev/null
+++ b/packages/shared-auth/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/shared-config/package.json b/packages/shared-config/package.json
new file mode 100644
index 000000000..f4a0c1a52
--- /dev/null
+++ b/packages/shared-config/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@manacore/shared-config",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./env": "./src/env.ts",
+ "./api": "./src/api.ts",
+ "./features": "./src/features.ts"
+ },
+ "scripts": {
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "zod": "^3.24.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/shared-config/src/api.ts b/packages/shared-config/src/api.ts
new file mode 100644
index 000000000..33bd82a1e
--- /dev/null
+++ b/packages/shared-config/src/api.ts
@@ -0,0 +1,207 @@
+/**
+ * API endpoint construction utilities
+ */
+
+/**
+ * API configuration
+ */
+export interface ApiConfig {
+ /** Base URL for the API */
+ baseUrl: string;
+ /** API version prefix (e.g., 'v1') */
+ version?: string;
+ /** Default timeout in milliseconds */
+ timeout?: number;
+ /** Default headers */
+ headers?: Record;
+}
+
+/**
+ * Create API endpoint URL builder
+ */
+export function createApiBuilder(config: ApiConfig) {
+ const { baseUrl, version } = config;
+
+ // Remove trailing slash from base URL
+ const base = baseUrl.replace(/\/$/, '');
+
+ // Build base path with optional version
+ const basePath = version ? `${base}/${version}` : base;
+
+ return {
+ /**
+ * Build endpoint URL from path segments
+ */
+ endpoint(...segments: (string | number)[]): string {
+ const path = segments
+ .map(String)
+ .map(s => s.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
+ .filter(Boolean)
+ .join('/');
+
+ return `${basePath}/${path}`;
+ },
+
+ /**
+ * Build endpoint URL with query parameters
+ */
+ endpointWithQuery(
+ path: string | string[],
+ params?: Record
+ ): string {
+ const segments = Array.isArray(path) ? path : [path];
+ const url = this.endpoint(...segments);
+
+ if (!params) {
+ return url;
+ }
+
+ const searchParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ searchParams.append(key, String(value));
+ }
+ }
+
+ const queryString = searchParams.toString();
+ return queryString ? `${url}?${queryString}` : url;
+ },
+
+ /**
+ * Get the base URL
+ */
+ getBaseUrl(): string {
+ return basePath;
+ },
+
+ /**
+ * Get the config
+ */
+ getConfig(): ApiConfig {
+ return config;
+ },
+ };
+}
+
+/**
+ * Build URL with query parameters
+ */
+export function buildUrl(
+ baseUrl: string,
+ path: string,
+ params?: Record
+): string {
+ // Ensure single slash between base and path
+ const base = baseUrl.replace(/\/$/, '');
+ const cleanPath = path.replace(/^\//, '');
+ const url = `${base}/${cleanPath}`;
+
+ if (!params) {
+ return url;
+ }
+
+ const searchParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ searchParams.append(key, String(value));
+ }
+ }
+
+ const queryString = searchParams.toString();
+ return queryString ? `${url}?${queryString}` : url;
+}
+
+/**
+ * Parse URL and extract components
+ */
+export function parseUrl(url: string): {
+ protocol: string;
+ host: string;
+ port: string;
+ pathname: string;
+ search: string;
+ params: Record;
+} {
+ const urlObj = new URL(url);
+
+ const params: Record = {};
+ urlObj.searchParams.forEach((value, key) => {
+ params[key] = value;
+ });
+
+ return {
+ protocol: urlObj.protocol.replace(':', ''),
+ host: urlObj.hostname,
+ port: urlObj.port,
+ pathname: urlObj.pathname,
+ search: urlObj.search,
+ params,
+ };
+}
+
+/**
+ * Join URL path segments
+ */
+export function joinPath(...segments: string[]): string {
+ return segments
+ .map(s => s.replace(/^\/+|\/+$/g, ''))
+ .filter(Boolean)
+ .join('/');
+}
+
+/**
+ * Common HTTP methods
+ */
+export const HTTP_METHODS = {
+ GET: 'GET',
+ POST: 'POST',
+ PUT: 'PUT',
+ PATCH: 'PATCH',
+ DELETE: 'DELETE',
+ HEAD: 'HEAD',
+ OPTIONS: 'OPTIONS',
+} as const;
+
+export type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS];
+
+/**
+ * Common HTTP status codes
+ */
+export const HTTP_STATUS = {
+ OK: 200,
+ CREATED: 201,
+ NO_CONTENT: 204,
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ CONFLICT: 409,
+ UNPROCESSABLE_ENTITY: 422,
+ TOO_MANY_REQUESTS: 429,
+ INTERNAL_SERVER_ERROR: 500,
+ BAD_GATEWAY: 502,
+ SERVICE_UNAVAILABLE: 503,
+} as const;
+
+export type HttpStatus = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
+
+/**
+ * Check if status code is successful (2xx)
+ */
+export function isSuccessStatus(status: number): boolean {
+ return status >= 200 && status < 300;
+}
+
+/**
+ * Check if status code is client error (4xx)
+ */
+export function isClientError(status: number): boolean {
+ return status >= 400 && status < 500;
+}
+
+/**
+ * Check if status code is server error (5xx)
+ */
+export function isServerError(status: number): boolean {
+ return status >= 500 && status < 600;
+}
diff --git a/packages/shared-config/src/env.ts b/packages/shared-config/src/env.ts
new file mode 100644
index 000000000..54628c6f9
--- /dev/null
+++ b/packages/shared-config/src/env.ts
@@ -0,0 +1,173 @@
+/**
+ * Environment variable validation utilities
+ */
+
+import { z } from 'zod';
+
+/**
+ * Common environment variable schemas
+ */
+export const envSchemas = {
+ /** URL schema */
+ url: z.string().url(),
+
+ /** Non-empty string schema */
+ nonEmpty: z.string().min(1),
+
+ /** Optional string schema */
+ optional: z.string().optional(),
+
+ /** Port number schema */
+ port: z.coerce.number().int().min(1).max(65535),
+
+ /** Boolean schema (accepts various formats) */
+ boolean: z.preprocess(
+ (val) => {
+ if (typeof val === 'boolean') return val;
+ if (typeof val === 'string') {
+ return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
+ }
+ return false;
+ },
+ z.boolean()
+ ),
+
+ /** Number schema */
+ number: z.coerce.number(),
+
+ /** Positive integer schema */
+ positiveInt: z.coerce.number().int().positive(),
+
+ /** Email schema */
+ email: z.string().email(),
+
+ /** Node environment schema */
+ nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
+};
+
+/**
+ * Common Supabase environment schema
+ */
+export const supabaseEnvSchema = z.object({
+ SUPABASE_URL: envSchemas.url,
+ SUPABASE_ANON_KEY: envSchemas.nonEmpty,
+ SUPABASE_SERVICE_ROLE_KEY: envSchemas.nonEmpty.optional(),
+});
+
+/**
+ * Common app environment schema
+ */
+export const appEnvSchema = z.object({
+ NODE_ENV: envSchemas.nodeEnv,
+ PORT: envSchemas.port.default(3000),
+});
+
+/**
+ * Create an environment config from schema
+ */
+export function createEnvConfig(
+ schema: T,
+ env: NodeJS.ProcessEnv = process.env
+): z.infer {
+ const result = schema.safeParse(env);
+
+ if (!result.success) {
+ const errors = result.error.errors
+ .map((err) => ` ${err.path.join('.')}: ${err.message}`)
+ .join('\n');
+
+ throw new Error(`Environment validation failed:\n${errors}`);
+ }
+
+ return result.data;
+}
+
+/**
+ * Validate environment variables with custom schema
+ */
+export function validateEnv(
+ schema: z.ZodObject,
+ env: NodeJS.ProcessEnv = process.env
+): z.infer> {
+ return createEnvConfig(schema, env);
+}
+
+/**
+ * Get required environment variable with type safety
+ */
+export function getRequiredEnv(key: string, env: NodeJS.ProcessEnv = process.env): string {
+ const value = env[key];
+
+ if (value === undefined || value === '') {
+ throw new Error(`Required environment variable "${key}" is not set`);
+ }
+
+ return value;
+}
+
+/**
+ * Get optional environment variable with default
+ */
+export function getEnv(
+ key: string,
+ defaultValue: string,
+ env: NodeJS.ProcessEnv = process.env
+): string {
+ return env[key] ?? defaultValue;
+}
+
+/**
+ * Get boolean environment variable
+ */
+export function getBoolEnv(
+ key: string,
+ defaultValue: boolean = false,
+ env: NodeJS.ProcessEnv = process.env
+): boolean {
+ const value = env[key];
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
+}
+
+/**
+ * Get number environment variable
+ */
+export function getNumEnv(
+ key: string,
+ defaultValue: number,
+ env: NodeJS.ProcessEnv = process.env
+): number {
+ const value = env[key];
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ const parsed = Number(value);
+ return isNaN(parsed) ? defaultValue : parsed;
+}
+
+/**
+ * Check if running in development
+ */
+export function isDevelopment(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env.NODE_ENV === 'development';
+}
+
+/**
+ * Check if running in production
+ */
+export function isProduction(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env.NODE_ENV === 'production';
+}
+
+/**
+ * Check if running in test
+ */
+export function isTest(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env.NODE_ENV === 'test';
+}
diff --git a/packages/shared-config/src/features.ts b/packages/shared-config/src/features.ts
new file mode 100644
index 000000000..3852f6df8
--- /dev/null
+++ b/packages/shared-config/src/features.ts
@@ -0,0 +1,173 @@
+/**
+ * Feature flag utilities
+ */
+
+/**
+ * Feature flag configuration
+ */
+export interface FeatureFlag {
+ /** Feature key */
+ key: string;
+ /** Default enabled state */
+ defaultEnabled: boolean;
+ /** Description */
+ description?: string;
+ /** Environment variable to override */
+ envVar?: string;
+}
+
+/**
+ * Create a feature flag manager
+ */
+export function createFeatureFlags>(
+ flags: T,
+ env: NodeJS.ProcessEnv = process.env
+) {
+ type FlagKey = keyof T;
+
+ /**
+ * Check if a feature is enabled
+ */
+ function isEnabled(key: FlagKey): boolean {
+ const flag = flags[key];
+
+ if (!flag) {
+ return false;
+ }
+
+ // Check environment variable override
+ if (flag.envVar) {
+ const envValue = env[flag.envVar];
+ if (envValue !== undefined) {
+ return ['true', '1', 'yes', 'on'].includes(envValue.toLowerCase());
+ }
+ }
+
+ // Check generic feature flag env var
+ const genericEnvVar = `FEATURE_${String(key).toUpperCase()}`;
+ const genericValue = env[genericEnvVar];
+ if (genericValue !== undefined) {
+ return ['true', '1', 'yes', 'on'].includes(genericValue.toLowerCase());
+ }
+
+ return flag.defaultEnabled;
+ }
+
+ /**
+ * Get all enabled features
+ */
+ function getEnabledFeatures(): FlagKey[] {
+ return (Object.keys(flags) as FlagKey[]).filter(isEnabled);
+ }
+
+ /**
+ * Get all disabled features
+ */
+ function getDisabledFeatures(): FlagKey[] {
+ return (Object.keys(flags) as FlagKey[]).filter(key => !isEnabled(key));
+ }
+
+ /**
+ * Get feature configuration
+ */
+ function getFlag(key: FlagKey): FeatureFlag | undefined {
+ return flags[key];
+ }
+
+ /**
+ * Get all flags with their current state
+ */
+ function getAllFlags(): Record {
+ const result: Record = {};
+ for (const key of Object.keys(flags) as FlagKey[]) {
+ result[String(key)] = isEnabled(key);
+ }
+ return result;
+ }
+
+ return {
+ isEnabled,
+ getEnabledFeatures,
+ getDisabledFeatures,
+ getFlag,
+ getAllFlags,
+ };
+}
+
+/**
+ * Simple feature check using environment variable
+ */
+export function isFeatureEnabled(
+ featureName: string,
+ defaultValue: boolean = false,
+ env: NodeJS.ProcessEnv = process.env
+): boolean {
+ const envVar = `FEATURE_${featureName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
+ const value = env[envVar];
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
+}
+
+/**
+ * App metadata configuration
+ */
+export interface AppMetadata {
+ /** App name */
+ name: string;
+ /** App version */
+ version: string;
+ /** App description */
+ description?: string;
+ /** Build number */
+ buildNumber?: string;
+ /** Git commit hash */
+ commitHash?: string;
+ /** Build timestamp */
+ buildTime?: string;
+ /** Environment */
+ environment?: string;
+}
+
+/**
+ * Create app metadata from environment
+ */
+export function createAppMetadata(
+ config: {
+ name: string;
+ version: string;
+ description?: string;
+ },
+ env: NodeJS.ProcessEnv = process.env
+): AppMetadata {
+ return {
+ name: config.name,
+ version: config.version,
+ description: config.description,
+ buildNumber: env.BUILD_NUMBER || env.VITE_BUILD_NUMBER,
+ commitHash: env.COMMIT_HASH || env.VITE_COMMIT_HASH || env.GIT_COMMIT,
+ buildTime: env.BUILD_TIME || env.VITE_BUILD_TIME,
+ environment: env.NODE_ENV || 'development',
+ };
+}
+
+/**
+ * Format version string with build info
+ */
+export function formatVersion(metadata: AppMetadata): string {
+ let version = metadata.version;
+
+ if (metadata.buildNumber) {
+ version += ` (${metadata.buildNumber})`;
+ }
+
+ if (metadata.commitHash) {
+ const shortHash = metadata.commitHash.substring(0, 7);
+ version += ` [${shortHash}]`;
+ }
+
+ return version;
+}
diff --git a/packages/shared-config/src/index.ts b/packages/shared-config/src/index.ts
new file mode 100644
index 000000000..94129fec4
--- /dev/null
+++ b/packages/shared-config/src/index.ts
@@ -0,0 +1,48 @@
+/**
+ * Shared configuration utilities for Manacore monorepo
+ *
+ * This package provides common configuration utilities including
+ * environment validation, API endpoint construction, and feature flags.
+ */
+
+// Environment utilities
+export {
+ envSchemas,
+ supabaseEnvSchema,
+ appEnvSchema,
+ createEnvConfig,
+ validateEnv,
+ getRequiredEnv,
+ getEnv,
+ getBoolEnv,
+ getNumEnv,
+ isDevelopment,
+ isProduction,
+ isTest,
+} from './env';
+
+// API utilities
+export {
+ type ApiConfig,
+ createApiBuilder,
+ buildUrl,
+ parseUrl,
+ joinPath,
+ HTTP_METHODS,
+ HTTP_STATUS,
+ type HttpMethod,
+ type HttpStatus,
+ isSuccessStatus,
+ isClientError,
+ isServerError,
+} from './api';
+
+// Feature flag utilities
+export {
+ type FeatureFlag,
+ createFeatureFlags,
+ isFeatureEnabled,
+ type AppMetadata,
+ createAppMetadata,
+ formatVersion,
+} from './features';
diff --git a/packages/shared-config/tsconfig.json b/packages/shared-config/tsconfig.json
new file mode 100644
index 000000000..121a61a7f
--- /dev/null
+++ b/packages/shared-config/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/shared-i18n/package.json b/packages/shared-i18n/package.json
new file mode 100644
index 000000000..03c2b5e19
--- /dev/null
+++ b/packages/shared-i18n/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@manacore/shared-i18n",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./languages": "./src/languages.ts",
+ "./utils": "./src/utils.ts",
+ "./translations/common": "./src/translations/common/index.ts"
+ },
+ "scripts": {
+ "type-check": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/shared-i18n/src/index.ts b/packages/shared-i18n/src/index.ts
new file mode 100644
index 000000000..ffc2acc42
--- /dev/null
+++ b/packages/shared-i18n/src/index.ts
@@ -0,0 +1,46 @@
+/**
+ * Shared i18n for Manacore monorepo
+ *
+ * This package provides common i18n utilities, language definitions,
+ * and translations that can be shared across all projects.
+ */
+
+// Language definitions
+export {
+ type LanguageCode,
+ type LanguageInfo,
+ LANGUAGES,
+ getLanguageCodes,
+ getLanguageInfo,
+ isLanguageSupported,
+ isRTL,
+ getLanguageDisplayName,
+ LOCALE_GROUPS,
+ getLanguagesByGroup,
+} from './languages';
+
+// Utilities
+export {
+ detectBrowserLocale,
+ getStoredLocale,
+ storeLocale,
+ getInitialLocale,
+ normalizeLocale,
+ getBaseLanguage,
+ matchesLanguage,
+ findBestMatch,
+ formatLocalizedNumber,
+ formatLocalizedDate,
+ formatRelativeTime,
+ getPluralCategory,
+ interpolate,
+} from './utils';
+
+// Common translations
+export {
+ en as commonTranslationsEn,
+ de as commonTranslationsDe,
+ type CommonTranslations,
+ getCommonTranslations,
+ mergeWithCommon,
+} from './translations/common';
diff --git a/packages/shared-i18n/src/languages.ts b/packages/shared-i18n/src/languages.ts
new file mode 100644
index 000000000..6d2286c05
--- /dev/null
+++ b/packages/shared-i18n/src/languages.ts
@@ -0,0 +1,159 @@
+/**
+ * Language definitions and metadata
+ */
+
+/**
+ * Supported language codes
+ */
+export type LanguageCode =
+ | 'en' | 'de' | 'fr' | 'es' | 'it' | 'pt' | 'nl' | 'pl' | 'ru' | 'ja'
+ | 'ko' | 'zh' | 'ar' | 'hi' | 'bn' | 'ur' | 'id' | 'fa' | 'vi' | 'th'
+ | 'tr' | 'uk' | 'cs' | 'da' | 'fi' | 'sv' | 'nb' | 'el' | 'hu' | 'ro'
+ | 'bg' | 'hr' | 'sk' | 'sl' | 'sr' | 'lt' | 'lv' | 'et' | 'mt' | 'ga'
+ | 'tl' | 'ms' | 'he' | 'af' | 'pt-BR' | 'es-MX';
+
+/**
+ * Language metadata
+ */
+export interface LanguageInfo {
+ /** Native name of the language */
+ nativeName: string;
+ /** English name of the language */
+ englishName: string;
+ /** Flag emoji */
+ emoji: string;
+ /** RTL language */
+ rtl?: boolean;
+}
+
+/**
+ * Complete language definitions
+ */
+export const LANGUAGES: Record = {
+ // Major languages
+ en: { nativeName: 'English', englishName: 'English', emoji: '🇬🇧' },
+ de: { nativeName: 'Deutsch', englishName: 'German', emoji: '🇩🇪' },
+ fr: { nativeName: 'Français', englishName: 'French', emoji: '🇫🇷' },
+ es: { nativeName: 'Español', englishName: 'Spanish', emoji: '🇪🇸' },
+ it: { nativeName: 'Italiano', englishName: 'Italian', emoji: '🇮🇹' },
+ pt: { nativeName: 'Português', englishName: 'Portuguese', emoji: '🇵🇹' },
+ nl: { nativeName: 'Nederlands', englishName: 'Dutch', emoji: '🇳🇱' },
+ pl: { nativeName: 'Polski', englishName: 'Polish', emoji: '🇵🇱' },
+ ru: { nativeName: 'Русский', englishName: 'Russian', emoji: '🇷🇺' },
+
+ // Asian languages
+ ja: { nativeName: '日本語', englishName: 'Japanese', emoji: '🇯🇵' },
+ ko: { nativeName: '한국어', englishName: 'Korean', emoji: '🇰🇷' },
+ zh: { nativeName: '中文', englishName: 'Chinese', emoji: '🇨🇳' },
+ vi: { nativeName: 'Tiếng Việt', englishName: 'Vietnamese', emoji: '🇻🇳' },
+ th: { nativeName: 'ไทย', englishName: 'Thai', emoji: '🇹🇭' },
+ id: { nativeName: 'Bahasa Indonesia', englishName: 'Indonesian', emoji: '🇮🇩' },
+ ms: { nativeName: 'Bahasa Melayu', englishName: 'Malay', emoji: '🇲🇾' },
+ tl: { nativeName: 'Filipino', englishName: 'Filipino', emoji: '🇵🇭' },
+
+ // South Asian languages
+ hi: { nativeName: 'हिन्दी', englishName: 'Hindi', emoji: '🇮🇳' },
+ bn: { nativeName: 'বাংলা', englishName: 'Bengali', emoji: '🇧🇩' },
+ ur: { nativeName: 'اردو', englishName: 'Urdu', emoji: '🇵🇰', rtl: true },
+
+ // Middle Eastern languages
+ ar: { nativeName: 'العربية', englishName: 'Arabic', emoji: '🇦🇪', rtl: true },
+ fa: { nativeName: 'فارسی', englishName: 'Persian', emoji: '🇮🇷', rtl: true },
+ he: { nativeName: 'עברית', englishName: 'Hebrew', emoji: '🇮🇱', rtl: true },
+ tr: { nativeName: 'Türkçe', englishName: 'Turkish', emoji: '🇹🇷' },
+
+ // Nordic languages
+ sv: { nativeName: 'Svenska', englishName: 'Swedish', emoji: '🇸🇪' },
+ da: { nativeName: 'Dansk', englishName: 'Danish', emoji: '🇩🇰' },
+ fi: { nativeName: 'Suomi', englishName: 'Finnish', emoji: '🇫🇮' },
+ nb: { nativeName: 'Norsk', englishName: 'Norwegian', emoji: '🇳🇴' },
+
+ // Eastern European languages
+ uk: { nativeName: 'Українська', englishName: 'Ukrainian', emoji: '🇺🇦' },
+ cs: { nativeName: 'Čeština', englishName: 'Czech', emoji: '🇨🇿' },
+ hu: { nativeName: 'Magyar', englishName: 'Hungarian', emoji: '🇭🇺' },
+ ro: { nativeName: 'Română', englishName: 'Romanian', emoji: '🇷🇴' },
+ bg: { nativeName: 'Български', englishName: 'Bulgarian', emoji: '🇧🇬' },
+ hr: { nativeName: 'Hrvatski', englishName: 'Croatian', emoji: '🇭🇷' },
+ sk: { nativeName: 'Slovenčina', englishName: 'Slovak', emoji: '🇸🇰' },
+ sl: { nativeName: 'Slovenščina', englishName: 'Slovenian', emoji: '🇸🇮' },
+ sr: { nativeName: 'Српски', englishName: 'Serbian', emoji: '🇷🇸' },
+
+ // Baltic languages
+ lt: { nativeName: 'Lietuvių', englishName: 'Lithuanian', emoji: '🇱🇹' },
+ lv: { nativeName: 'Latviešu', englishName: 'Latvian', emoji: '🇱🇻' },
+ et: { nativeName: 'Eesti', englishName: 'Estonian', emoji: '🇪🇪' },
+
+ // Other European languages
+ el: { nativeName: 'Ελληνικά', englishName: 'Greek', emoji: '🇬🇷' },
+ mt: { nativeName: 'Malti', englishName: 'Maltese', emoji: '🇲🇹' },
+ ga: { nativeName: 'Gaeilge', englishName: 'Irish', emoji: '🇮🇪' },
+
+ // African languages
+ af: { nativeName: 'Afrikaans', englishName: 'Afrikaans', emoji: '🇿🇦' },
+
+ // Regional variants
+ 'pt-BR': { nativeName: 'Português (Brasil)', englishName: 'Portuguese (Brazil)', emoji: '🇧🇷' },
+ 'es-MX': { nativeName: 'Español (México)', englishName: 'Spanish (Mexico)', emoji: '🇲🇽' },
+};
+
+/**
+ * Get list of all language codes
+ */
+export function getLanguageCodes(): LanguageCode[] {
+ return Object.keys(LANGUAGES) as LanguageCode[];
+}
+
+/**
+ * Get language info by code
+ */
+export function getLanguageInfo(code: string): LanguageInfo | undefined {
+ return LANGUAGES[code as LanguageCode];
+}
+
+/**
+ * Check if a language code is supported
+ */
+export function isLanguageSupported(code: string): code is LanguageCode {
+ return code in LANGUAGES;
+}
+
+/**
+ * Check if a language is RTL
+ */
+export function isRTL(code: string): boolean {
+ const info = LANGUAGES[code as LanguageCode];
+ return info?.rtl === true;
+}
+
+/**
+ * Get display name for a language (native name with emoji)
+ */
+export function getLanguageDisplayName(code: string): string {
+ const info = LANGUAGES[code as LanguageCode];
+ if (!info) return code;
+ return `${info.emoji} ${info.nativeName}`;
+}
+
+/**
+ * Common locale groups for filtering
+ */
+export const LOCALE_GROUPS = {
+ /** European Union official languages */
+ eu: ['en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da', 'fi', 'sv', 'el', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'lt', 'lv', 'et', 'mt', 'ga'] as LanguageCode[],
+ /** Major world languages */
+ major: ['en', 'de', 'fr', 'es', 'it', 'pt', 'ru', 'ja', 'ko', 'zh', 'ar'] as LanguageCode[],
+ /** DACH region (German-speaking) */
+ dach: ['de'] as LanguageCode[],
+ /** Nordic countries */
+ nordic: ['sv', 'da', 'fi', 'nb'] as LanguageCode[],
+ /** RTL languages */
+ rtl: ['ar', 'fa', 'he', 'ur'] as LanguageCode[],
+};
+
+/**
+ * Get languages by group
+ */
+export function getLanguagesByGroup(group: keyof typeof LOCALE_GROUPS): LanguageCode[] {
+ return LOCALE_GROUPS[group];
+}
diff --git a/packages/shared-i18n/src/translations/common/de.json b/packages/shared-i18n/src/translations/common/de.json
new file mode 100644
index 000000000..999e5999f
--- /dev/null
+++ b/packages/shared-i18n/src/translations/common/de.json
@@ -0,0 +1,172 @@
+{
+ "common": {
+ "actions": {
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "delete": "Löschen",
+ "edit": "Bearbeiten",
+ "create": "Erstellen",
+ "update": "Aktualisieren",
+ "close": "Schließen",
+ "confirm": "Bestätigen",
+ "submit": "Absenden",
+ "back": "Zurück",
+ "next": "Weiter",
+ "done": "Fertig",
+ "retry": "Erneut versuchen",
+ "refresh": "Aktualisieren",
+ "search": "Suchen",
+ "filter": "Filtern",
+ "sort": "Sortieren",
+ "share": "Teilen",
+ "copy": "Kopieren",
+ "download": "Herunterladen",
+ "upload": "Hochladen",
+ "select": "Auswählen",
+ "clear": "Leeren",
+ "reset": "Zurücksetzen",
+ "apply": "Anwenden",
+ "continue": "Fortfahren",
+ "skip": "Überspringen",
+ "yes": "Ja",
+ "no": "Nein",
+ "ok": "OK"
+ },
+ "labels": {
+ "loading": "Lädt...",
+ "saving": "Speichert...",
+ "deleting": "Löscht...",
+ "processing": "Verarbeitet...",
+ "uploading": "Lädt hoch...",
+ "downloading": "Lädt herunter...",
+ "searching": "Sucht...",
+ "noResults": "Keine Ergebnisse gefunden",
+ "noData": "Keine Daten verfügbar",
+ "empty": "Leer",
+ "all": "Alle",
+ "none": "Keine",
+ "other": "Andere",
+ "more": "Mehr",
+ "less": "Weniger",
+ "showMore": "Mehr anzeigen",
+ "showLess": "Weniger anzeigen",
+ "viewAll": "Alle anzeigen",
+ "required": "Erforderlich",
+ "optional": "Optional",
+ "new": "Neu",
+ "recent": "Aktuell",
+ "popular": "Beliebt",
+ "featured": "Empfohlen"
+ },
+ "time": {
+ "now": "Jetzt",
+ "today": "Heute",
+ "yesterday": "Gestern",
+ "tomorrow": "Morgen",
+ "thisWeek": "Diese Woche",
+ "lastWeek": "Letzte Woche",
+ "thisMonth": "Diesen Monat",
+ "lastMonth": "Letzten Monat",
+ "thisYear": "Dieses Jahr",
+ "ago": "vor",
+ "in": "in"
+ },
+ "status": {
+ "active": "Aktiv",
+ "inactive": "Inaktiv",
+ "pending": "Ausstehend",
+ "completed": "Abgeschlossen",
+ "failed": "Fehlgeschlagen",
+ "cancelled": "Abgebrochen",
+ "success": "Erfolg",
+ "error": "Fehler",
+ "warning": "Warnung",
+ "info": "Info"
+ }
+ },
+ "errors": {
+ "generic": "Etwas ist schief gelaufen. Bitte versuche es erneut.",
+ "network": "Netzwerkfehler. Bitte überprüfe deine Verbindung.",
+ "timeout": "Zeitüberschreitung. Bitte versuche es erneut.",
+ "notFound": "Das angeforderte Element wurde nicht gefunden.",
+ "unauthorized": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
+ "forbidden": "Zugriff verweigert.",
+ "serverError": "Serverfehler. Bitte versuche es später erneut.",
+ "validation": "Bitte überprüfe deine Eingabe und versuche es erneut.",
+ "unknown": "Ein unbekannter Fehler ist aufgetreten.",
+ "offline": "Du bist offline. Bitte überprüfe deine Internetverbindung.",
+ "sessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
+ "rateLimited": "Zu viele Anfragen. Bitte warte einen Moment und versuche es erneut."
+ },
+ "validation": {
+ "required": "Dieses Feld ist erforderlich",
+ "email": "Bitte gib eine gültige E-Mail-Adresse ein",
+ "minLength": "Muss mindestens {min} Zeichen lang sein",
+ "maxLength": "Darf höchstens {max} Zeichen lang sein",
+ "min": "Muss mindestens {min} sein",
+ "max": "Darf höchstens {max} sein",
+ "pattern": "Ungültiges Format",
+ "match": "Felder stimmen nicht überein",
+ "unique": "Dieser Wert wird bereits verwendet",
+ "invalid": "Ungültiger Wert",
+ "url": "Bitte gib eine gültige URL ein",
+ "phone": "Bitte gib eine gültige Telefonnummer ein",
+ "number": "Bitte gib eine gültige Zahl ein",
+ "integer": "Bitte gib eine ganze Zahl ein",
+ "positive": "Muss eine positive Zahl sein",
+ "date": "Bitte gib ein gültiges Datum ein",
+ "futureDate": "Datum muss in der Zukunft liegen",
+ "pastDate": "Datum muss in der Vergangenheit liegen",
+ "password": {
+ "minLength": "Passwort muss mindestens {min} Zeichen lang sein",
+ "uppercase": "Passwort muss einen Großbuchstaben enthalten",
+ "lowercase": "Passwort muss einen Kleinbuchstaben enthalten",
+ "number": "Passwort muss eine Zahl enthalten",
+ "special": "Passwort muss ein Sonderzeichen enthalten",
+ "weak": "Passwort ist zu schwach"
+ }
+ },
+ "auth": {
+ "signIn": "Anmelden",
+ "signOut": "Abmelden",
+ "signUp": "Registrieren",
+ "forgotPassword": "Passwort vergessen?",
+ "resetPassword": "Passwort zurücksetzen",
+ "changePassword": "Passwort ändern",
+ "email": "E-Mail",
+ "password": "Passwort",
+ "confirmPassword": "Passwort bestätigen",
+ "rememberMe": "Angemeldet bleiben",
+ "orContinueWith": "Oder fortfahren mit",
+ "alreadyHaveAccount": "Bereits ein Konto?",
+ "dontHaveAccount": "Noch kein Konto?",
+ "errors": {
+ "invalidCredentials": "Ungültige E-Mail oder Passwort",
+ "emailInUse": "Diese E-Mail wird bereits verwendet",
+ "weakPassword": "Passwort ist zu schwach",
+ "userNotFound": "Benutzer nicht gefunden",
+ "tooManyAttempts": "Zu viele Versuche. Bitte versuche es später erneut."
+ }
+ },
+ "settings": {
+ "title": "Einstellungen",
+ "account": "Konto",
+ "profile": "Profil",
+ "preferences": "Einstellungen",
+ "notifications": "Benachrichtigungen",
+ "privacy": "Datenschutz",
+ "security": "Sicherheit",
+ "language": "Sprache",
+ "theme": "Design",
+ "appearance": "Erscheinungsbild",
+ "darkMode": "Dunkelmodus",
+ "lightMode": "Hellmodus",
+ "systemDefault": "Systemstandard",
+ "about": "Über",
+ "help": "Hilfe",
+ "feedback": "Feedback",
+ "terms": "Nutzungsbedingungen",
+ "privacyPolicy": "Datenschutzrichtlinie",
+ "version": "Version"
+ }
+}
diff --git a/packages/shared-i18n/src/translations/common/en.json b/packages/shared-i18n/src/translations/common/en.json
new file mode 100644
index 000000000..1b9d53b36
--- /dev/null
+++ b/packages/shared-i18n/src/translations/common/en.json
@@ -0,0 +1,172 @@
+{
+ "common": {
+ "actions": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "edit": "Edit",
+ "create": "Create",
+ "update": "Update",
+ "close": "Close",
+ "confirm": "Confirm",
+ "submit": "Submit",
+ "back": "Back",
+ "next": "Next",
+ "done": "Done",
+ "retry": "Retry",
+ "refresh": "Refresh",
+ "search": "Search",
+ "filter": "Filter",
+ "sort": "Sort",
+ "share": "Share",
+ "copy": "Copy",
+ "download": "Download",
+ "upload": "Upload",
+ "select": "Select",
+ "clear": "Clear",
+ "reset": "Reset",
+ "apply": "Apply",
+ "continue": "Continue",
+ "skip": "Skip",
+ "yes": "Yes",
+ "no": "No",
+ "ok": "OK"
+ },
+ "labels": {
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "deleting": "Deleting...",
+ "processing": "Processing...",
+ "uploading": "Uploading...",
+ "downloading": "Downloading...",
+ "searching": "Searching...",
+ "noResults": "No results found",
+ "noData": "No data available",
+ "empty": "Empty",
+ "all": "All",
+ "none": "None",
+ "other": "Other",
+ "more": "More",
+ "less": "Less",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "viewAll": "View all",
+ "required": "Required",
+ "optional": "Optional",
+ "new": "New",
+ "recent": "Recent",
+ "popular": "Popular",
+ "featured": "Featured"
+ },
+ "time": {
+ "now": "Now",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "tomorrow": "Tomorrow",
+ "thisWeek": "This week",
+ "lastWeek": "Last week",
+ "thisMonth": "This month",
+ "lastMonth": "Last month",
+ "thisYear": "This year",
+ "ago": "ago",
+ "in": "in"
+ },
+ "status": {
+ "active": "Active",
+ "inactive": "Inactive",
+ "pending": "Pending",
+ "completed": "Completed",
+ "failed": "Failed",
+ "cancelled": "Cancelled",
+ "success": "Success",
+ "error": "Error",
+ "warning": "Warning",
+ "info": "Info"
+ }
+ },
+ "errors": {
+ "generic": "Something went wrong. Please try again.",
+ "network": "Network error. Please check your connection.",
+ "timeout": "Request timed out. Please try again.",
+ "notFound": "The requested item was not found.",
+ "unauthorized": "You are not authorized to perform this action.",
+ "forbidden": "Access denied.",
+ "serverError": "Server error. Please try again later.",
+ "validation": "Please check your input and try again.",
+ "unknown": "An unknown error occurred.",
+ "offline": "You are offline. Please check your internet connection.",
+ "sessionExpired": "Your session has expired. Please sign in again.",
+ "rateLimited": "Too many requests. Please wait a moment and try again."
+ },
+ "validation": {
+ "required": "This field is required",
+ "email": "Please enter a valid email address",
+ "minLength": "Must be at least {min} characters",
+ "maxLength": "Must be at most {max} characters",
+ "min": "Must be at least {min}",
+ "max": "Must be at most {max}",
+ "pattern": "Invalid format",
+ "match": "Fields do not match",
+ "unique": "This value is already in use",
+ "invalid": "Invalid value",
+ "url": "Please enter a valid URL",
+ "phone": "Please enter a valid phone number",
+ "number": "Please enter a valid number",
+ "integer": "Please enter a whole number",
+ "positive": "Must be a positive number",
+ "date": "Please enter a valid date",
+ "futureDate": "Date must be in the future",
+ "pastDate": "Date must be in the past",
+ "password": {
+ "minLength": "Password must be at least {min} characters",
+ "uppercase": "Password must contain an uppercase letter",
+ "lowercase": "Password must contain a lowercase letter",
+ "number": "Password must contain a number",
+ "special": "Password must contain a special character",
+ "weak": "Password is too weak"
+ }
+ },
+ "auth": {
+ "signIn": "Sign in",
+ "signOut": "Sign out",
+ "signUp": "Sign up",
+ "forgotPassword": "Forgot password?",
+ "resetPassword": "Reset password",
+ "changePassword": "Change password",
+ "email": "Email",
+ "password": "Password",
+ "confirmPassword": "Confirm password",
+ "rememberMe": "Remember me",
+ "orContinueWith": "Or continue with",
+ "alreadyHaveAccount": "Already have an account?",
+ "dontHaveAccount": "Don't have an account?",
+ "errors": {
+ "invalidCredentials": "Invalid email or password",
+ "emailInUse": "This email is already in use",
+ "weakPassword": "Password is too weak",
+ "userNotFound": "User not found",
+ "tooManyAttempts": "Too many attempts. Please try again later."
+ }
+ },
+ "settings": {
+ "title": "Settings",
+ "account": "Account",
+ "profile": "Profile",
+ "preferences": "Preferences",
+ "notifications": "Notifications",
+ "privacy": "Privacy",
+ "security": "Security",
+ "language": "Language",
+ "theme": "Theme",
+ "appearance": "Appearance",
+ "darkMode": "Dark mode",
+ "lightMode": "Light mode",
+ "systemDefault": "System default",
+ "about": "About",
+ "help": "Help",
+ "feedback": "Feedback",
+ "terms": "Terms of Service",
+ "privacyPolicy": "Privacy Policy",
+ "version": "Version"
+ }
+}
diff --git a/packages/shared-i18n/src/translations/common/index.ts b/packages/shared-i18n/src/translations/common/index.ts
new file mode 100644
index 000000000..b156e3184
--- /dev/null
+++ b/packages/shared-i18n/src/translations/common/index.ts
@@ -0,0 +1,37 @@
+/**
+ * Common translations exports
+ */
+
+import en from './en.json';
+import de from './de.json';
+
+export { en, de };
+
+/**
+ * Common translations type
+ */
+export type CommonTranslations = typeof en;
+
+/**
+ * Get common translations by locale
+ */
+export function getCommonTranslations(locale: string): CommonTranslations {
+ switch (locale) {
+ case 'de':
+ return de;
+ case 'en':
+ default:
+ return en;
+ }
+}
+
+/**
+ * Merge common translations with app-specific translations
+ */
+export function mergeWithCommon>(
+ locale: string,
+ appTranslations: T
+): T & CommonTranslations {
+ const common = getCommonTranslations(locale);
+ return { ...common, ...appTranslations } as T & CommonTranslations;
+}
diff --git a/packages/shared-i18n/src/utils.ts b/packages/shared-i18n/src/utils.ts
new file mode 100644
index 000000000..3b5f1f13c
--- /dev/null
+++ b/packages/shared-i18n/src/utils.ts
@@ -0,0 +1,249 @@
+/**
+ * i18n utility functions
+ */
+
+import { type LanguageCode, isLanguageSupported } from './languages';
+
+/**
+ * Detect user's preferred locale from browser
+ * Works in browser environment only
+ */
+export function detectBrowserLocale(
+ supportedLocales: readonly string[],
+ defaultLocale: string = 'en'
+): string {
+ if (typeof navigator === 'undefined') {
+ return defaultLocale;
+ }
+
+ // Try navigator.language first
+ const browserLang = navigator.language;
+
+ // Check exact match (e.g., 'pt-BR')
+ if (supportedLocales.includes(browserLang)) {
+ return browserLang;
+ }
+
+ // Check base language (e.g., 'pt' from 'pt-BR')
+ const baseLang = browserLang.split('-')[0];
+ if (supportedLocales.includes(baseLang)) {
+ return baseLang;
+ }
+
+ // Try navigator.languages array
+ if (navigator.languages) {
+ for (const lang of navigator.languages) {
+ if (supportedLocales.includes(lang)) {
+ return lang;
+ }
+ const base = lang.split('-')[0];
+ if (supportedLocales.includes(base)) {
+ return base;
+ }
+ }
+ }
+
+ return defaultLocale;
+}
+
+/**
+ * Get locale from localStorage with validation
+ */
+export function getStoredLocale(
+ storageKey: string,
+ supportedLocales: readonly string[]
+): string | null {
+ if (typeof localStorage === 'undefined') {
+ return null;
+ }
+
+ const stored = localStorage.getItem(storageKey);
+ if (stored && supportedLocales.includes(stored)) {
+ return stored;
+ }
+
+ return null;
+}
+
+/**
+ * Store locale in localStorage
+ */
+export function storeLocale(storageKey: string, locale: string): void {
+ if (typeof localStorage === 'undefined') {
+ return;
+ }
+
+ localStorage.setItem(storageKey, locale);
+}
+
+/**
+ * Get initial locale with priority:
+ * 1. localStorage
+ * 2. Browser language
+ * 3. Default locale
+ */
+export function getInitialLocale(
+ storageKey: string,
+ supportedLocales: readonly string[],
+ defaultLocale: string = 'en'
+): string {
+ // Check localStorage first
+ const stored = getStoredLocale(storageKey, supportedLocales);
+ if (stored) {
+ return stored;
+ }
+
+ // Fall back to browser language
+ return detectBrowserLocale(supportedLocales, defaultLocale);
+}
+
+/**
+ * Normalize locale code to standard format
+ * Examples: 'en-us' -> 'en-US', 'pt_BR' -> 'pt-BR'
+ */
+export function normalizeLocale(locale: string): string {
+ const parts = locale.replace('_', '-').split('-');
+
+ if (parts.length === 1) {
+ return parts[0].toLowerCase();
+ }
+
+ return `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`;
+}
+
+/**
+ * Get base language from locale code
+ * Examples: 'pt-BR' -> 'pt', 'en-US' -> 'en'
+ */
+export function getBaseLanguage(locale: string): string {
+ return locale.split('-')[0].toLowerCase();
+}
+
+/**
+ * Check if locale matches a language (including variants)
+ * Examples: matchesLanguage('pt-BR', 'pt') -> true
+ */
+export function matchesLanguage(locale: string, language: string): boolean {
+ const normalizedLocale = normalizeLocale(locale);
+ const normalizedLanguage = language.toLowerCase();
+
+ return (
+ normalizedLocale === normalizedLanguage ||
+ getBaseLanguage(normalizedLocale) === normalizedLanguage
+ );
+}
+
+/**
+ * Find best matching locale from supported list
+ */
+export function findBestMatch(
+ preferredLocale: string,
+ supportedLocales: readonly string[],
+ defaultLocale: string = 'en'
+): string {
+ const normalized = normalizeLocale(preferredLocale);
+
+ // Exact match
+ if (supportedLocales.includes(normalized)) {
+ return normalized;
+ }
+
+ // Base language match
+ const base = getBaseLanguage(normalized);
+ if (supportedLocales.includes(base)) {
+ return base;
+ }
+
+ // Find any variant of the same language
+ const variant = supportedLocales.find(loc => getBaseLanguage(loc) === base);
+ if (variant) {
+ return variant;
+ }
+
+ return defaultLocale;
+}
+
+/**
+ * Format number according to locale
+ */
+export function formatLocalizedNumber(
+ value: number,
+ locale: string = 'en',
+ options?: Intl.NumberFormatOptions
+): string {
+ return new Intl.NumberFormat(locale, options).format(value);
+}
+
+/**
+ * Format date according to locale
+ */
+export function formatLocalizedDate(
+ date: Date | string | number,
+ locale: string = 'en',
+ options?: Intl.DateTimeFormatOptions
+): string {
+ const dateObj = date instanceof Date ? date : new Date(date);
+ return new Intl.DateTimeFormat(locale, options).format(dateObj);
+}
+
+/**
+ * Format relative time according to locale
+ */
+export function formatRelativeTime(
+ date: Date | string | number,
+ locale: string = 'en',
+ style: 'long' | 'short' | 'narrow' = 'long'
+): string {
+ const dateObj = date instanceof Date ? date : new Date(date);
+ const now = new Date();
+ const diffMs = dateObj.getTime() - now.getTime();
+ const diffSecs = Math.round(diffMs / 1000);
+ const diffMins = Math.round(diffSecs / 60);
+ const diffHours = Math.round(diffMins / 60);
+ const diffDays = Math.round(diffHours / 24);
+ const diffWeeks = Math.round(diffDays / 7);
+ const diffMonths = Math.round(diffDays / 30);
+ const diffYears = Math.round(diffDays / 365);
+
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style });
+
+ if (Math.abs(diffSecs) < 60) {
+ return rtf.format(diffSecs, 'second');
+ } else if (Math.abs(diffMins) < 60) {
+ return rtf.format(diffMins, 'minute');
+ } else if (Math.abs(diffHours) < 24) {
+ return rtf.format(diffHours, 'hour');
+ } else if (Math.abs(diffDays) < 7) {
+ return rtf.format(diffDays, 'day');
+ } else if (Math.abs(diffWeeks) < 4) {
+ return rtf.format(diffWeeks, 'week');
+ } else if (Math.abs(diffMonths) < 12) {
+ return rtf.format(diffMonths, 'month');
+ } else {
+ return rtf.format(diffYears, 'year');
+ }
+}
+
+/**
+ * Get plural form category
+ */
+export function getPluralCategory(
+ count: number,
+ locale: string = 'en'
+): Intl.LDMLPluralRule {
+ const pr = new Intl.PluralRules(locale);
+ return pr.select(count);
+}
+
+/**
+ * Interpolate values into a translation string
+ * Example: interpolate("Hello {name}!", { name: "World" }) -> "Hello World!"
+ */
+export function interpolate(
+ text: string,
+ values: Record
+): string {
+ return text.replace(/\{(\w+)\}/g, (match, key) => {
+ return key in values ? String(values[key]) : match;
+ });
+}
diff --git a/packages/shared-i18n/tsconfig.json b/packages/shared-i18n/tsconfig.json
new file mode 100644
index 000000000..121a61a7f
--- /dev/null
+++ b/packages/shared-i18n/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/shared-icons/package.json b/packages/shared-icons/package.json
new file mode 100644
index 000000000..6c4cda791
--- /dev/null
+++ b/packages/shared-icons/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@manacore/shared-icons",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Shared Phosphor Icons (Bold) for Manacore SvelteKit web apps",
+ "type": "module",
+ "svelte": "./src/index.ts",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "svelte": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./Icon.svelte": {
+ "svelte": "./src/Icon.svelte",
+ "default": "./src/Icon.svelte"
+ },
+ "./iconPaths": {
+ "default": "./src/iconPaths.ts"
+ }
+ },
+ "scripts": {
+ "check": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ },
+ "devDependencies": {
+ "svelte": "^5.16.6",
+ "svelte-check": "^4.2.1",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/shared-icons/src/Icon.svelte b/packages/shared-icons/src/Icon.svelte
new file mode 100644
index 000000000..b6f1e2eb5
--- /dev/null
+++ b/packages/shared-icons/src/Icon.svelte
@@ -0,0 +1,39 @@
+
+
+{#if path}
+
+ {@html path}
+
+{:else}
+ ⚠
+{/if}
diff --git a/packages/shared-icons/src/iconPaths.ts b/packages/shared-icons/src/iconPaths.ts
new file mode 100644
index 000000000..db0dbbd0f
--- /dev/null
+++ b/packages/shared-icons/src/iconPaths.ts
@@ -0,0 +1,131 @@
+/**
+ * Phosphor Icons (Bold weight) - Shared icons for Manacore web apps
+ *
+ * This is a centralized icon catalog for all SvelteKit applications.
+ * All icons use the Bold weight for consistency.
+ *
+ * To add new icons:
+ * 1. Find the icon at https://phosphoricons.com/
+ * 2. Copy the SVG content (the tag) from the Bold variant
+ * 3. Add it to this file with a descriptive key
+ *
+ * Usage:
+ * import { Icon } from '@manacore/shared-icons';
+ *
+ */
+
+export const iconPaths = {
+ // Auth & User
+ 'user-plus':
+ ' ',
+ 'sign-in':
+ ' ',
+ 'sign-out':
+ ' ',
+ user: ' ',
+ users: ' ',
+
+ // Navigation & Arrows
+ 'arrow-left':
+ ' ',
+ 'arrow-right':
+ ' ',
+ 'arrow-up':
+ ' ',
+ 'arrow-down':
+ ' ',
+ 'caret-down':
+ ' ',
+ 'caret-up':
+ ' ',
+ 'caret-left':
+ ' ',
+ 'caret-right':
+ ' ',
+
+ // Actions
+ plus: ' ',
+ minus: ' ',
+ x: ' ',
+ check: ' ',
+ trash: ' ',
+ copy: ' ',
+
+ // Media
+ play: ' ',
+ pause: ' ',
+ microphone:
+ ' ',
+ 'skip-back':
+ ' ',
+ 'skip-forward':
+ ' ',
+
+ // Edit
+ pencil: ' ',
+ pen: ' ',
+ 'note-pencil':
+ ' ',
+
+ // Files & Folders
+ folder: ' ',
+ 'folder-open':
+ ' ',
+ file: ' ',
+
+ // UI Elements
+ 'dots-three':
+ ' ',
+ 'dots-three-vertical':
+ ' ',
+ list: ' ',
+ 'magnifying-glass':
+ ' ',
+
+ // Misc
+ key: ' ',
+ info: ' ',
+ tag: ' ',
+ share: ' ',
+ download:
+ ' ',
+ upload: ' ',
+ link: ' ',
+ eye: ' ',
+ 'eye-slash':
+ ' ',
+ // Alias for eye-slash
+ 'eye-off':
+ ' ',
+ lock: ' ',
+ star: ' ',
+ heart: ' ',
+ bell: ' ',
+ calendar:
+ ' ',
+ clock: ' ',
+ image: ' ',
+ 'shield-check':
+ ' ',
+ envelope:
+ ' ',
+ 'envelope-open':
+ ' ',
+ 'mail-open':
+ ' ',
+ 'arrows-left-right':
+ ' ',
+ globe: ' ',
+ gear: ' ',
+ 'warning':
+ ' ',
+ 'question':
+ ' ',
+ 'house':
+ ' ',
+ music: ' ',
+ refresh:
+ ' '
+} as const;
+
+export type IconName = keyof typeof iconPaths;
diff --git a/packages/shared-icons/src/index.ts b/packages/shared-icons/src/index.ts
new file mode 100644
index 000000000..c2639295c
--- /dev/null
+++ b/packages/shared-icons/src/index.ts
@@ -0,0 +1,2 @@
+export { default as Icon } from './Icon.svelte';
+export { iconPaths, type IconName } from './iconPaths';
diff --git a/packages/shared-icons/tsconfig.json b/packages/shared-icons/tsconfig.json
new file mode 100644
index 000000000..8f94f3e0e
--- /dev/null
+++ b/packages/shared-icons/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte"]
+}
diff --git a/packages/shared-subscription-types/package.json b/packages/shared-subscription-types/package.json
new file mode 100644
index 000000000..47ec9f764
--- /dev/null
+++ b/packages/shared-subscription-types/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@manacore/shared-subscription-types",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./plans": "./src/plans.ts",
+ "./usage": "./src/usage.ts",
+ "./revenueCat": "./src/revenueCat.ts"
+ },
+ "scripts": {
+ "type-check": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/shared-subscription-types/src/index.ts b/packages/shared-subscription-types/src/index.ts
new file mode 100644
index 000000000..58ae14977
--- /dev/null
+++ b/packages/shared-subscription-types/src/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Shared subscription types for Manacore monorepo
+ *
+ * This package contains TypeScript types for subscription plans,
+ * mana packages, usage tracking, and RevenueCat integration.
+ */
+
+// Plan types
+export {
+ type BillingCycle,
+ type PlanCategory,
+ type SubscriptionPlan,
+ type ManaPackage,
+ type ProductMapping,
+ type PackageMapping,
+ type FreeTierConfig,
+ DEFAULT_FREE_TIER,
+} from './plans';
+
+// Usage types
+export {
+ type UsageData,
+ type UsageHistoryEntry,
+ type CostItem,
+ type ManaBalance,
+ type CreditTransaction,
+ type OperationPricing,
+} from './usage';
+
+// RevenueCat types
+export {
+ type RevenueCatSubscriptionPlan,
+ type RevenueCatManaPackage,
+ type SubscriptionServiceData,
+ type PurchaseResult,
+ type CustomerSubscriptionStatus,
+ type RestorePurchasesResult,
+ type RevenueCatOffering,
+} from './revenueCat';
diff --git a/packages/shared-subscription-types/src/plans.ts b/packages/shared-subscription-types/src/plans.ts
new file mode 100644
index 000000000..65488a47d
--- /dev/null
+++ b/packages/shared-subscription-types/src/plans.ts
@@ -0,0 +1,136 @@
+/**
+ * Subscription plan and package types
+ */
+
+/**
+ * Billing cycle options
+ */
+export type BillingCycle = 'monthly' | 'yearly';
+
+/**
+ * Subscription plan category
+ */
+export type PlanCategory = 'individual' | 'team' | 'enterprise';
+
+/**
+ * Base subscription plan interface
+ */
+export interface SubscriptionPlan {
+ /** Unique identifier */
+ id: string;
+ /** Display name (localized) */
+ name: string;
+ /** English name */
+ nameEn?: string;
+ /** German name */
+ nameDe?: string;
+ /** Italian name */
+ nameIt?: string;
+ /** Price in local currency */
+ price: number;
+ /** Formatted price string (e.g., "5,99€") */
+ priceString?: string;
+ /** Currency code (e.g., "EUR") */
+ currencyCode?: string;
+ /** Price breakdown text */
+ priceBreakdown?: string;
+ /** Monthly equivalent for yearly plans */
+ monthlyEquivalent?: number;
+ /** Mana amount per month */
+ monthlyMana: number;
+ /** Initial mana grant on signup */
+ initialMana?: number;
+ /** Daily mana regeneration */
+ dailyMana?: number;
+ /** Maximum mana capacity */
+ maxMana?: number;
+ /** Whether user can gift mana */
+ canGiftMana: boolean;
+ /** Mark as popular/recommended */
+ popular?: boolean;
+ /** Billing frequency */
+ billingCycle: BillingCycle;
+ /** Team subscription flag */
+ isTeamSubscription?: boolean;
+ /** Enterprise subscription flag */
+ isEnterpriseSubscription?: boolean;
+ /** Plan features list */
+ features?: string[];
+}
+
+/**
+ * One-time mana package interface
+ */
+export interface ManaPackage {
+ /** Unique identifier */
+ id: string;
+ /** Display name (localized) */
+ name: string;
+ /** English name */
+ nameEn?: string;
+ /** German name */
+ nameDe?: string;
+ /** Italian name */
+ nameIt?: string;
+ /** Mana amount */
+ manaAmount: number;
+ /** Price in local currency */
+ price: number;
+ /** Formatted price string */
+ priceString?: string;
+ /** Currency code */
+ currencyCode?: string;
+ /** Team package flag */
+ isTeamPackage?: boolean;
+ /** Enterprise package flag */
+ isEnterprisePackage?: boolean;
+ /** Mark as popular */
+ popular?: boolean;
+}
+
+/**
+ * Product mapping for RevenueCat
+ */
+export interface ProductMapping {
+ /** Internal subscription ID */
+ subscriptionId: string;
+ /** App Store/Play Store product ID */
+ productId: string;
+ /** Billing cycle */
+ billingCycle: BillingCycle;
+ /** Category */
+ category: PlanCategory;
+}
+
+/**
+ * Package mapping for RevenueCat
+ */
+export interface PackageMapping {
+ /** Internal package ID */
+ packageId: string;
+ /** App Store/Play Store product ID */
+ productId: string;
+ /** Category */
+ category: PlanCategory;
+}
+
+/**
+ * Free tier configuration
+ */
+export interface FreeTierConfig {
+ /** Initial mana for free users */
+ initialMana: number;
+ /** Daily mana regeneration */
+ dailyMana: number;
+ /** Maximum mana capacity */
+ maxMana: number;
+}
+
+/**
+ * Default free tier configuration
+ */
+export const DEFAULT_FREE_TIER: FreeTierConfig = {
+ initialMana: 150,
+ dailyMana: 5,
+ maxMana: 150,
+};
diff --git a/packages/shared-subscription-types/src/revenueCat.ts b/packages/shared-subscription-types/src/revenueCat.ts
new file mode 100644
index 000000000..5f0eb079d
--- /dev/null
+++ b/packages/shared-subscription-types/src/revenueCat.ts
@@ -0,0 +1,99 @@
+/**
+ * RevenueCat integration types
+ */
+
+import type { SubscriptionPlan, ManaPackage } from './plans';
+
+/**
+ * RevenueCat-enhanced subscription plan
+ */
+export interface RevenueCatSubscriptionPlan extends SubscriptionPlan {
+ /** RevenueCat package object */
+ revenueCatPackage?: unknown;
+ /** RevenueCat product object */
+ revenueCatProduct?: unknown;
+ /** App Store/Play Store product ID */
+ productId: string;
+}
+
+/**
+ * RevenueCat-enhanced mana package
+ */
+export interface RevenueCatManaPackage extends ManaPackage {
+ /** RevenueCat package object */
+ revenueCatPackage?: unknown;
+ /** RevenueCat product object */
+ revenueCatProduct?: unknown;
+ /** App Store/Play Store product ID */
+ productId: string;
+}
+
+/**
+ * Subscription service data response
+ */
+export interface SubscriptionServiceData {
+ /** All available subscription plans */
+ subscriptions: RevenueCatSubscriptionPlan[];
+ /** All available one-time packages */
+ packages: RevenueCatManaPackage[];
+ /** Whether data is from RevenueCat or fallback */
+ isFromRevenueCat: boolean;
+ /** Last update timestamp */
+ lastUpdated: Date;
+}
+
+/**
+ * Purchase result
+ */
+export interface PurchaseResult {
+ /** Whether purchase was successful */
+ success: boolean;
+ /** Customer info from RevenueCat */
+ customerInfo?: unknown;
+ /** Error message if failed */
+ error?: string;
+}
+
+/**
+ * Customer subscription status
+ */
+export interface CustomerSubscriptionStatus {
+ /** Whether user has active subscription */
+ hasActiveSubscription: boolean;
+ /** Current plan ID */
+ currentPlanId?: string;
+ /** Subscription expiration date */
+ expirationDate?: Date;
+ /** Whether in grace period */
+ isInGracePeriod?: boolean;
+ /** Whether subscription will renew */
+ willRenew?: boolean;
+}
+
+/**
+ * Restore purchases result
+ */
+export interface RestorePurchasesResult {
+ /** Whether restore was successful */
+ success: boolean;
+ /** Restored subscription plan ID */
+ restoredPlanId?: string;
+ /** Error message if failed */
+ error?: string;
+}
+
+/**
+ * Offering from RevenueCat
+ */
+export interface RevenueCatOffering {
+ /** Offering identifier */
+ identifier: string;
+ /** Available packages in this offering */
+ availablePackages: RevenueCatSubscriptionPlan[];
+ /** Lifetime package (if available) */
+ lifetime?: RevenueCatManaPackage;
+ /** Annual package */
+ annual?: RevenueCatSubscriptionPlan;
+ /** Monthly package */
+ monthly?: RevenueCatSubscriptionPlan;
+}
diff --git a/packages/shared-subscription-types/src/usage.ts b/packages/shared-subscription-types/src/usage.ts
new file mode 100644
index 000000000..a5be88e5b
--- /dev/null
+++ b/packages/shared-subscription-types/src/usage.ts
@@ -0,0 +1,93 @@
+/**
+ * Usage and cost tracking types
+ */
+
+/**
+ * Usage data for displaying user's mana consumption
+ */
+export interface UsageData {
+ /** Total mana consumed all time */
+ total: number;
+ /** Mana consumed last week */
+ lastWeek: number;
+ /** Mana consumed last month */
+ lastMonth: number;
+ /** Current mana balance */
+ currentMana: number;
+ /** Maximum mana capacity */
+ maxMana: number;
+ /** Usage history */
+ history?: UsageHistoryEntry[];
+}
+
+/**
+ * Single usage history entry
+ */
+export interface UsageHistoryEntry {
+ /** Date of usage (ISO string) */
+ date: string;
+ /** Amount consumed */
+ amount: number;
+ /** Action type (optional) */
+ action?: string;
+}
+
+/**
+ * Cost item for displaying operation costs
+ */
+export interface CostItem {
+ /** Action description */
+ action: string;
+ /** Translation key for action */
+ actionKey?: string;
+ /** Mana cost */
+ cost: number;
+ /** Icon name */
+ icon: string;
+}
+
+/**
+ * User's credit/mana balance
+ */
+export interface ManaBalance {
+ /** Current mana amount */
+ current: number;
+ /** Maximum capacity */
+ max: number;
+ /** Last updated timestamp */
+ lastUpdated: string;
+}
+
+/**
+ * Credit transaction record
+ */
+export interface CreditTransaction {
+ /** Transaction ID */
+ id: string;
+ /** User ID */
+ userId: string;
+ /** Amount (positive = credit, negative = debit) */
+ amount: number;
+ /** Transaction type */
+ type: 'purchase' | 'subscription' | 'usage' | 'gift' | 'refund' | 'bonus';
+ /** Description */
+ description: string;
+ /** Timestamp */
+ createdAt: string;
+ /** Related operation ID (if applicable) */
+ operationId?: string;
+}
+
+/**
+ * Pricing information for operations
+ */
+export interface OperationPricing {
+ /** Operation key */
+ operation: string;
+ /** Base cost in mana */
+ baseCost: number;
+ /** Per-unit cost (e.g., per minute, per token) */
+ perUnitCost?: number;
+ /** Unit type */
+ unitType?: 'minute' | 'token' | 'request';
+}
diff --git a/packages/shared-subscription-types/tsconfig.json b/packages/shared-subscription-types/tsconfig.json
new file mode 100644
index 000000000..121a61a7f
--- /dev/null
+++ b/packages/shared-subscription-types/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/shared-subscription-ui/package.json b/packages/shared-subscription-ui/package.json
new file mode 100644
index 000000000..f7313168f
--- /dev/null
+++ b/packages/shared-subscription-ui/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@manacore/shared-subscription-ui",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "svelte": "./src/index.ts",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./SubscriptionCard.svelte": "./src/SubscriptionCard.svelte",
+ "./PackageCard.svelte": "./src/PackageCard.svelte",
+ "./BillingToggle.svelte": "./src/BillingToggle.svelte",
+ "./UsageCard.svelte": "./src/UsageCard.svelte",
+ "./CostCard.svelte": "./src/CostCard.svelte",
+ "./SubscriptionButton.svelte": "./src/SubscriptionButton.svelte",
+ "./ManaIcon.svelte": "./src/ManaIcon.svelte"
+ },
+ "scripts": {
+ "check": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "dependencies": {
+ "@manacore/shared-subscription-types": "workspace:*"
+ },
+ "devDependencies": {
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.7.3"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+}
diff --git a/memoro/apps/web/src/lib/components/BillingToggle.svelte b/packages/shared-subscription-ui/src/BillingToggle.svelte
similarity index 78%
rename from memoro/apps/web/src/lib/components/BillingToggle.svelte
rename to packages/shared-subscription-ui/src/BillingToggle.svelte
index 9ddce0301..1b401f95f 100644
--- a/memoro/apps/web/src/lib/components/BillingToggle.svelte
+++ b/packages/shared-subscription-ui/src/BillingToggle.svelte
@@ -1,13 +1,21 @@