mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()
This commit is contained in:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
manadeck/apps/web/src/lib/auth.ts
Normal file
177
manadeck/apps/web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Manadeck Web Auth Configuration
|
||||
*
|
||||
* This file initializes the shared auth package for the manadeck web app.
|
||||
* It replaces the previous individual auth files:
|
||||
* - services/authService.ts
|
||||
* - services/tokenManager.ts
|
||||
* - services/deviceManager.ts
|
||||
* - utils/jwt.ts
|
||||
*/
|
||||
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
setupFetchInterceptor,
|
||||
type StorageAdapter,
|
||||
type DeviceManagerAdapter,
|
||||
type NetworkAdapter,
|
||||
type DeviceInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'appToken',
|
||||
REFRESH_TOKEN: 'refreshToken',
|
||||
USER_EMAIL: 'userEmail',
|
||||
DEVICE_ID: 'manadeck_device_id',
|
||||
};
|
||||
|
||||
/**
|
||||
* Session storage adapter for manadeck web
|
||||
* Uses sessionStorage for tokens (clears on tab close)
|
||||
* Uses localStorage for device ID (persists)
|
||||
*/
|
||||
const sessionStorageAdapter: StorageAdapter = {
|
||||
async getItem<T = string>(key: string): Promise<T | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as T;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Device manager adapter for web
|
||||
*/
|
||||
const webDeviceAdapter: DeviceManagerAdapter = {
|
||||
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
deviceId: '',
|
||||
deviceName: 'Server',
|
||||
deviceType: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const deviceId = await webDeviceAdapter.getStoredDeviceId() || generateDeviceId();
|
||||
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
let deviceName = 'Web Browser';
|
||||
|
||||
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
||||
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
||||
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: 'web',
|
||||
platform: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async getStoredDeviceId(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Network adapter for web
|
||||
*/
|
||||
const webNetworkAdapter: NetworkAdapter = {
|
||||
async isDeviceConnected(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
|
||||
async hasStableConnection(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique device ID
|
||||
*/
|
||||
function generateDeviceId(): string {
|
||||
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
// Initialize adapters
|
||||
setStorageAdapter(sessionStorageAdapter);
|
||||
setDeviceAdapter(webDeviceAdapter);
|
||||
setNetworkAdapter(webNetworkAdapter);
|
||||
|
||||
// Create auth service instance
|
||||
export const authService = createAuthService({
|
||||
baseUrl: PUBLIC_API_URL,
|
||||
storageKeys: {
|
||||
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
|
||||
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
|
||||
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
|
||||
},
|
||||
endpoints: {
|
||||
signIn: '/v1/auth/signin',
|
||||
signUp: '/v1/auth/signup',
|
||||
signOut: '/v1/auth/logout',
|
||||
refresh: '/v1/auth/refresh',
|
||||
validate: '/v1/auth/validate',
|
||||
forgotPassword: '/v1/auth/forgot-password',
|
||||
googleSignIn: '/v1/auth/google-signin',
|
||||
appleSignIn: '/v1/auth/apple-signin',
|
||||
credits: '/v1/auth/credits',
|
||||
},
|
||||
});
|
||||
|
||||
// Create token manager instance
|
||||
export const tokenManager = createTokenManager(authService);
|
||||
|
||||
// Setup fetch interceptor (only in browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
setupFetchInterceptor(authService, tokenManager, {
|
||||
backendUrl: PUBLIC_API_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export useful utilities from shared-auth
|
||||
export {
|
||||
decodeToken,
|
||||
isTokenValidLocally,
|
||||
isTokenExpired,
|
||||
getUserFromToken,
|
||||
isB2BUser,
|
||||
getB2BInfo,
|
||||
TokenState,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
UserData,
|
||||
DecodedToken,
|
||||
AuthResult,
|
||||
CreditBalance,
|
||||
B2BInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
34
manadeck/apps/web/src/lib/components/Icon.svelte
Normal file
34
manadeck/apps/web/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Icon Component - Uses @manacore/shared-icons
|
||||
* Phosphor Icons (Bold weight)
|
||||
*/
|
||||
import { iconPaths } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
name: keyof typeof iconPaths;
|
||||
size?: number;
|
||||
class?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||
|
||||
const path = $derived(iconPaths[name]);
|
||||
</script>
|
||||
|
||||
{#if path}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color || 'currentColor'}
|
||||
viewBox="0 0 256 256"
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html path}
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||
{/if}
|
||||
28
manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
Normal file
28
manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { size = 55, color = '#8b5cf6' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- Cards/Deck icon -->
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 2v2" />
|
||||
<path d="M18 2v2" />
|
||||
<path d="M6 20v2" />
|
||||
<path d="M18 20v2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import type {
|
||||
SignInResponse,
|
||||
SignUpResponse,
|
||||
ManaUser,
|
||||
CreditBalance
|
||||
} from '$lib/types/auth';
|
||||
import { getUserFromToken } from '$lib/utils/jwt';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import { getDeviceInfo } from './deviceManager';
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<SignInResponse> {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
deviceId: deviceInfo.deviceId,
|
||||
deviceName: deviceInfo.deviceName,
|
||||
deviceType: deviceInfo.deviceType
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Sign in failed');
|
||||
}
|
||||
|
||||
const data: SignInResponse = await response.json();
|
||||
|
||||
// Store tokens
|
||||
tokenManager.setTokens(data.appToken, data.refreshToken);
|
||||
|
||||
// Extract user from token
|
||||
data.user = getUserFromToken(data.appToken) || undefined;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
username?: string
|
||||
): Promise<SignUpResponse> {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
deviceId: deviceInfo.deviceId,
|
||||
deviceName: deviceInfo.deviceName,
|
||||
deviceType: deviceInfo.deviceType
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Sign up failed');
|
||||
}
|
||||
|
||||
const data: SignUpResponse = await response.json();
|
||||
|
||||
// Store tokens
|
||||
tokenManager.setTokens(data.appToken, data.refreshToken);
|
||||
|
||||
// Extract user from token
|
||||
data.user = getUserFromToken(data.appToken) || undefined;
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
const appToken = tokenManager.getAppToken();
|
||||
|
||||
if (appToken) {
|
||||
try {
|
||||
await fetch(`${PUBLIC_API_URL}/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout request failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear tokens locally
|
||||
tokenManager.clearTokens();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user from token
|
||||
*/
|
||||
getCurrentUser(): ManaUser | null {
|
||||
const appToken = tokenManager.getAppToken();
|
||||
if (!appToken) return null;
|
||||
return getUserFromToken(appToken);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user credit balance
|
||||
*/
|
||||
async getCreditBalance(): Promise<CreditBalance> {
|
||||
const appToken = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/credits`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch credits');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!tokenManager.getAppToken() && !tokenManager.isExpired();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get app token
|
||||
*/
|
||||
getAppToken(): string | null {
|
||||
return tokenManager.getAppToken();
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import { decodeToken, isTokenExpired, getTokenExpiresIn } from '$lib/utils/jwt';
|
||||
import { getDeviceInfo } from './deviceManager';
|
||||
|
||||
const TOKEN_REFRESH_BUFFER = 10; // seconds before expiry to trigger refresh
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS = [0, 1000, 2000, 5000]; // Progressive backoff
|
||||
|
||||
interface TokenState {
|
||||
appToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isRefreshing: boolean;
|
||||
refreshPromise: Promise<string> | null;
|
||||
}
|
||||
|
||||
class TokenManager {
|
||||
private state: TokenState = {
|
||||
appToken: null,
|
||||
refreshToken: null,
|
||||
isRefreshing: false,
|
||||
refreshPromise: null
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.loadTokens();
|
||||
}
|
||||
}
|
||||
|
||||
private loadTokens() {
|
||||
this.state.appToken = sessionStorage.getItem('appToken');
|
||||
this.state.refreshToken = sessionStorage.getItem('refreshToken');
|
||||
}
|
||||
|
||||
private saveTokens(appToken: string, refreshToken: string) {
|
||||
this.state.appToken = appToken;
|
||||
this.state.refreshToken = refreshToken;
|
||||
sessionStorage.setItem('appToken', appToken);
|
||||
sessionStorage.setItem('refreshToken', refreshToken);
|
||||
}
|
||||
|
||||
clearTokens() {
|
||||
this.state.appToken = null;
|
||||
this.state.refreshToken = null;
|
||||
sessionStorage.removeItem('appToken');
|
||||
sessionStorage.removeItem('refreshToken');
|
||||
sessionStorage.removeItem('deviceId');
|
||||
}
|
||||
|
||||
getAppToken(): string | null {
|
||||
return this.state.appToken;
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.state.refreshToken;
|
||||
}
|
||||
|
||||
setTokens(appToken: string, refreshToken: string) {
|
||||
this.saveTokens(appToken, refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token needs refresh
|
||||
*/
|
||||
needsRefresh(): boolean {
|
||||
if (!this.state.appToken) return false;
|
||||
const expiresIn = getTokenExpiresIn(this.state.appToken);
|
||||
return expiresIn > 0 && expiresIn <= TOKEN_REFRESH_BUFFER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
isExpired(): boolean {
|
||||
if (!this.state.appToken) return true;
|
||||
return isTokenExpired(this.state.appToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry logic
|
||||
*/
|
||||
async refreshAppToken(retryCount = 0): Promise<string> {
|
||||
// If already refreshing, wait for that promise
|
||||
if (this.state.isRefreshing && this.state.refreshPromise) {
|
||||
return this.state.refreshPromise;
|
||||
}
|
||||
|
||||
// Start new refresh
|
||||
this.state.isRefreshing = true;
|
||||
this.state.refreshPromise = this._performRefresh(retryCount);
|
||||
|
||||
try {
|
||||
const newToken = await this.state.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.state.isRefreshing = false;
|
||||
this.state.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _performRefresh(retryCount: number): Promise<string> {
|
||||
if (!this.state.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: this.state.refreshToken,
|
||||
deviceId: deviceInfo.deviceId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Token refresh failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { appToken, refreshToken } = data;
|
||||
|
||||
// Save new tokens
|
||||
this.saveTokens(appToken, refreshToken);
|
||||
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
// Retry logic
|
||||
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
||||
const delay = RETRY_DELAYS[retryCount];
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.refreshAppToken(retryCount + 1);
|
||||
}
|
||||
|
||||
// Max retries reached, clear tokens
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid token (refreshes if needed)
|
||||
*/
|
||||
async getValidToken(): Promise<string> {
|
||||
if (!this.state.appToken) {
|
||||
throw new Error('No token available');
|
||||
}
|
||||
|
||||
if (this.isExpired()) {
|
||||
// Token is expired, try to refresh
|
||||
return this.refreshAppToken();
|
||||
}
|
||||
|
||||
if (this.needsRefresh()) {
|
||||
// Token expires soon, refresh in background
|
||||
this.refreshAppToken().catch(console.error);
|
||||
}
|
||||
|
||||
return this.state.appToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const tokenManager = new TokenManager();
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
import type { ManaUser } from '$lib/types/auth';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { authService, type UserData } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based auth store
|
||||
let user = $state<ManaUser | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
/**
|
||||
* Convert UserData from shared-auth to ManaUser
|
||||
*/
|
||||
function toManaUser(userData: UserData | null): ManaUser | null {
|
||||
if (!userData) return null;
|
||||
return {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
|
|
@ -22,8 +34,10 @@ export const authStore = {
|
|||
async initialize() {
|
||||
loading = true;
|
||||
try {
|
||||
if (authService.isAuthenticated()) {
|
||||
user = authService.getCurrentUser();
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (isAuth) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
|
|
@ -55,11 +69,43 @@ export const authStore = {
|
|||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
checkAuth() {
|
||||
if (!authService.isAuthenticated()) {
|
||||
async checkAuth() {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (!isAuth) {
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
if (result.success && !result.needsVerification) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async forgotPassword(email: string) {
|
||||
return authService.forgotPassword(email);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
|
||||
import { getAuthenticatedSupabase } from '$lib/utils/supabase';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { authService } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based deck store
|
||||
let decks = $state<Deck[]>([]);
|
||||
|
|
@ -30,12 +30,12 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const user = authService.getCurrentUser();
|
||||
const user = await authService.getUserFromToken();
|
||||
if (!user) {
|
||||
throw new Error('No user found');
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -104,10 +104,10 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const user = authService.getCurrentUser();
|
||||
const user = await authService.getUserFromToken();
|
||||
if (!user) throw new Error('No user found');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -150,7 +150,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -187,7 +187,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,83 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
error = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await authService.signIn(email, password);
|
||||
goto('/decks');
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Sign in failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.forgotPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign In - Manadeck</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
|
||||
<p class="text-muted-foreground">Sign in to your Manadeck account</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
bind:value={email}
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
bind:value={password}
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" fullWidth {loading}>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm">
|
||||
<span class="text-muted-foreground">Don't have an account?</span>
|
||||
<a href="/register" class="ml-2 text-primary hover:underline">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<LoginPage
|
||||
appName="ManaDeck"
|
||||
logo={ManaDeckLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
goto={goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/decks"
|
||||
registerPath="/register"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,112 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
error = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await authService.signUp(email, password, username || undefined);
|
||||
goto('/decks');
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Sign up failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign Up - Manadeck</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Create your account</h1>
|
||||
<p class="text-muted-foreground">Start building your knowledge decks</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
bind:value={email}
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Username (optional)"
|
||||
bind:value={username}
|
||||
placeholder="Choose a username"
|
||||
autocomplete="username"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
bind:value={password}
|
||||
placeholder="••••••••"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="••••••••"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" fullWidth {loading}>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm">
|
||||
<span class="text-muted-foreground">Already have an account?</span>
|
||||
<a href="/login" class="ml-2 text-primary hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<RegisterPage
|
||||
appName="ManaDeck"
|
||||
logo={ManaDeckLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
goto={goto}
|
||||
successRedirect="/decks"
|
||||
loginPath="/login"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue