mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(memoro/web): add Dockerfile + docker-compose for production deployment
- Dockerfile using sveltekit-base:local pattern (port 5038) - docker-compose.macmini.yml entry with Traefik labels for memoro.mana.how - Delete legacy authService.ts and auth.ts (app uses shared-auth-stores) - Remove middleware env vars from env.ts and app.d.ts (dead code) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
57db32f1b0
commit
9d77f12c1e
6 changed files with 108 additions and 828 deletions
62
apps/memoro/apps/web/Dockerfile
Normal file
62
apps/memoro/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Build stage - inherits pre-built shared packages from sveltekit-base
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars (baked into the build)
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL_CLIENT=https://auth.mana.how
|
||||
ARG PUBLIC_MEMORO_SERVER_URL=http://memoro-server:3015
|
||||
ARG PUBLIC_SUPABASE_URL
|
||||
ARG PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG PUBLIC_MANA_SYNC_URL=ws://mana-sync:3050
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL_CLIENT=$PUBLIC_MANA_CORE_AUTH_URL_CLIENT
|
||||
ENV PUBLIC_MEMORO_SERVER_URL=$PUBLIC_MEMORO_SERVER_URL
|
||||
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
|
||||
ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV PUBLIC_MANA_SYNC_URL=$PUBLIC_MANA_SYNC_URL
|
||||
|
||||
# Copy web app (memoro has no local packages/)
|
||||
COPY apps/memoro/apps/web ./apps/memoro/apps/web
|
||||
|
||||
# Install app-specific dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/memoro/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||
WORKDIR /app/apps/memoro/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
||||
COPY --from=builder /app/apps/memoro/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/memoro/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/memoro/apps/web/package.json ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5038
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5038
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5038/health || exit 1
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
3
apps/memoro/apps/web/src/app.d.ts
vendored
3
apps/memoro/apps/web/src/app.d.ts
vendored
|
|
@ -21,9 +21,6 @@ declare module '$env/static/public' {
|
|||
export const PUBLIC_MEMORO_SERVER_URL: string;
|
||||
export const PUBLIC_MANA_CORE_AUTH_URL: string;
|
||||
export const PUBLIC_MANA_CORE_AUTH_URL_CLIENT: string;
|
||||
export const PUBLIC_MEMORO_MIDDLEWARE_URL: string;
|
||||
export const PUBLIC_MANA_MIDDLEWARE_URL: string;
|
||||
export const PUBLIC_MIDDLEWARE_APP_ID: string;
|
||||
export const PUBLIC_STORAGE_BUCKET: string;
|
||||
export const PUBLIC_GOOGLE_CLIENT_ID: string;
|
||||
export const PUBLIC_APPLE_CLIENT_ID: string;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@
|
|||
import {
|
||||
PUBLIC_SUPABASE_URL,
|
||||
PUBLIC_SUPABASE_ANON_KEY,
|
||||
PUBLIC_MEMORO_MIDDLEWARE_URL,
|
||||
PUBLIC_MEMORO_SERVER_URL,
|
||||
PUBLIC_MANA_MIDDLEWARE_URL,
|
||||
PUBLIC_MIDDLEWARE_APP_ID,
|
||||
PUBLIC_STORAGE_BUCKET,
|
||||
PUBLIC_GOOGLE_CLIENT_ID,
|
||||
PUBLIC_APPLE_CLIENT_ID,
|
||||
|
|
@ -31,13 +28,6 @@ export const env = {
|
|||
memoroUrl: PUBLIC_MEMORO_SERVER_URL,
|
||||
},
|
||||
|
||||
// Middleware APIs (legacy — kept for authService compatibility during migration)
|
||||
middleware: {
|
||||
memoroUrl: PUBLIC_MEMORO_MIDDLEWARE_URL,
|
||||
manaUrl: PUBLIC_MANA_MIDDLEWARE_URL,
|
||||
appId: PUBLIC_MIDDLEWARE_APP_ID,
|
||||
},
|
||||
|
||||
// Storage
|
||||
storage: {
|
||||
bucket: PUBLIC_STORAGE_BUCKET,
|
||||
|
|
@ -74,7 +64,7 @@ export const features = {
|
|||
if (typeof window !== 'undefined') {
|
||||
console.log('🔧 Memoro Environment Configuration:', {
|
||||
supabase: !!env.supabase.url ? '✅ Configured' : '❌ Missing',
|
||||
middleware: !!env.middleware.memoroUrl ? '✅ Configured' : '❌ Missing',
|
||||
server: !!env.server.memoroUrl ? '✅ Configured' : '❌ Missing',
|
||||
appleClientId: env.oauth.appleClientId || '❌ NOT SET',
|
||||
appleRedirectUri: env.oauth.appleRedirectUri || '❌ NOT SET',
|
||||
googleOAuth: !!env.oauth.googleClientId ? '✅ Configured' : '❌ Missing',
|
||||
|
|
|
|||
|
|
@ -1,491 +0,0 @@
|
|||
/**
|
||||
* Authentication service for Memoro Web
|
||||
* Uses Mana middleware for authentication instead of direct Supabase auth
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
|
||||
const MIDDLEWARE_URL = env.middleware.memoroUrl;
|
||||
const APP_ID = env.middleware.appId;
|
||||
|
||||
// Storage keys for tokens
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'memoro_app_token',
|
||||
REFRESH_TOKEN: 'memoro_refresh_token',
|
||||
USER_EMAIL: 'memoro_user_email',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get device information for authentication
|
||||
*/
|
||||
function getDeviceInfo() {
|
||||
return {
|
||||
deviceId: getBrowserFingerprint(),
|
||||
deviceName: getBrowserName(),
|
||||
deviceType: 'web',
|
||||
platform: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a browser fingerprint for device identification
|
||||
*/
|
||||
function getBrowserFingerprint(): string {
|
||||
// Simple browser fingerprint based on available info
|
||||
const ua = navigator.userAgent;
|
||||
const screen = `${window.screen.width}x${window.screen.height}`;
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const lang = navigator.language;
|
||||
|
||||
// Create a consistent hash
|
||||
const data = `${ua}|${screen}|${timezone}|${lang}`;
|
||||
return btoa(data).slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser name
|
||||
*/
|
||||
function getBrowserName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.includes('Chrome')) return 'Chrome';
|
||||
if (ua.includes('Firefox')) return 'Firefox';
|
||||
if (ua.includes('Safari')) return 'Safari';
|
||||
if (ua.includes('Edge')) return 'Edge';
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
function decodeToken(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(window.atob(base64));
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
|
||||
// Add 10 second buffer
|
||||
const bufferTime = 10 * 1000;
|
||||
return Date.now() >= payload.exp * 1000 - bufferTime;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
appToken?: string;
|
||||
refreshToken?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication service
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 401) {
|
||||
if (
|
||||
errorData.message?.includes('Firebase user detected') ||
|
||||
errorData.message?.includes('password reset required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
errorData.message?.includes('Email not confirmed') ||
|
||||
errorData.message?.includes('Email not verified')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMAIL_NOT_VERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed',
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} 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<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
|
||||
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: 'This email is already in use',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Check if email verification is required
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during registration',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google ID token
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Google Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
// Try to extract email from token
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Google:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple identity token
|
||||
*/
|
||||
async signInWithApple(identityToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: identityToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Apple Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
// Try to extract email from token
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
async refreshTokens(currentRefreshToken: string): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: UserData | null;
|
||||
}> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh');
|
||||
}
|
||||
|
||||
// Extract user data from token
|
||||
let userData: UserData | null = null;
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (payload) {
|
||||
userData = {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding refreshed token:', error);
|
||||
}
|
||||
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate token
|
||||
*/
|
||||
async validateToken(appToken: string): Promise<boolean> {
|
||||
try {
|
||||
// First check if token is expired locally
|
||||
if (isTokenExpired(appToken)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate with server
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ appToken, appId: APP_ID }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error validating token:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => console.error('Error logging out on server:', err));
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
|
||||
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 password reset attempts. Please wait a few minutes 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',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user data from token
|
||||
*/
|
||||
getUserFromToken(appToken: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is valid locally (without network call)
|
||||
*/
|
||||
isTokenValidLocally(token: string): boolean {
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
/**
|
||||
* Auth Store for memoro-web
|
||||
* Manages authentication state using Mana middleware pattern from memoro_app
|
||||
*/
|
||||
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { authService, type UserData } from '$lib/services/authService';
|
||||
import { tokenManager, TokenState } from '$lib/services/tokenManager';
|
||||
import { clearAuthClient } from '$lib/supabaseClient';
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'memoro_app_token',
|
||||
REFRESH_TOKEN: 'memoro_refresh_token',
|
||||
USER_EMAIL: 'memoro_user_email',
|
||||
};
|
||||
|
||||
// Auth state
|
||||
interface AuthState {
|
||||
user: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Create writable store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
async function initialize() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
|
||||
|
||||
if (!token) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is valid locally
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
const userData = authService.getUserFromToken(token);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Token expired, try to refresh
|
||||
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
// Store new tokens
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token on init:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Could not refresh, clear state
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: 'Failed to initialize authentication',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with email and password
|
||||
async function signIn(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
update((state) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: result.error || 'Sign in failed',
|
||||
}));
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
if (result.appToken && result.refreshToken) {
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
|
||||
if (result.email) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
|
||||
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
// Sign up with email and password
|
||||
async function signUp(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> {
|
||||
update((state) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: result.error || 'Sign up failed',
|
||||
}));
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// Check if email verification is required
|
||||
if (result.needsVerification) {
|
||||
update((state) => ({ ...state, isLoading: false, error: null }));
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
if (result.appToken && result.refreshToken) {
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
|
||||
if (result.email) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
|
||||
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with Google
|
||||
async function signInWithGoogle(idToken: string): Promise<{ success: boolean; error?: string }> {
|
||||
update((state) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
|
||||
if (!result.success) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: result.error || 'Google Sign-In failed',
|
||||
}));
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
if (result.appToken && result.refreshToken) {
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
|
||||
if (result.email) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
|
||||
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with Apple
|
||||
// Note: On web, Apple returns an authorization code (not identity token like mobile)
|
||||
// The middleware needs to exchange the code for an identity token
|
||||
async function signInWithApple(
|
||||
authorizationCode: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
update((state) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// For web, we send the authorization code
|
||||
// Middleware will need to exchange it for an identity token
|
||||
// TODO: Confirm middleware supports authorization code exchange for Apple
|
||||
const result = await authService.signInWithApple(authorizationCode);
|
||||
|
||||
if (!result.success) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: result.error || 'Apple Sign-In failed',
|
||||
}));
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
if (result.appToken && result.refreshToken) {
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
|
||||
if (result.email) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
|
||||
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
// Sign out
|
||||
async function signOut(): Promise<void> {
|
||||
update((state) => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
if (refreshToken) {
|
||||
await authService.signOut(refreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sign out:', error);
|
||||
} finally {
|
||||
// Clear local storage
|
||||
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_EMAIL);
|
||||
|
||||
// Clear token manager
|
||||
await tokenManager.clearTokens();
|
||||
|
||||
// Clear Supabase client
|
||||
clearAuthClient();
|
||||
|
||||
// Reset state
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Forgot password
|
||||
async function forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
return authService.forgotPassword(email);
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
if (browser) {
|
||||
initialize();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
signIn,
|
||||
signUp,
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
signOut,
|
||||
forgotPassword,
|
||||
initialize,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const user = derived(auth, ($auth) => $auth.user);
|
||||
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
|
||||
export const isLoading = derived(auth, ($auth) => $auth.isLoading);
|
||||
|
|
@ -1527,6 +1527,51 @@ services:
|
|||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
memoro-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/memoro/apps/web/Dockerfile
|
||||
args:
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||
PUBLIC_MEMORO_SERVER_URL: http://memoro-server:3015
|
||||
PUBLIC_SUPABASE_URL: ${MEMORO_SUPABASE_URL}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${MEMORO_SUPABASE_ANON_KEY}
|
||||
PUBLIC_MANA_SYNC_URL: ws://mana-sync:3050
|
||||
image: memoro-web:local
|
||||
container_name: mana-app-memoro-web
|
||||
restart: always
|
||||
mem_limit: 128m
|
||||
depends_on:
|
||||
memoro-server:
|
||||
condition: service_healthy
|
||||
mana-auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5038
|
||||
HOST: 0.0.0.0
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||
PUBLIC_MEMORO_SERVER_URL: http://memoro-server:3015
|
||||
PUBLIC_SUPABASE_URL: ${MEMORO_SUPABASE_URL}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${MEMORO_SUPABASE_ANON_KEY}
|
||||
PUBLIC_MANA_SYNC_URL: ws://mana-sync:3050
|
||||
ports:
|
||||
- "5038:5038"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5038/health"]
|
||||
interval: 180s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.memoro-web.rule=Host(`memoro.mana.how`)"
|
||||
- "traefik.http.routers.memoro-web.entrypoints=websecure"
|
||||
- "traefik.http.routers.memoro-web.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.memoro-web.loadbalancer.server.port=5038"
|
||||
|
||||
mana-llm:
|
||||
build:
|
||||
context: ./services/mana-llm
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue