diff --git a/apps/skilltree/apps/web/.env.production.example b/apps/skilltree/apps/web/.env.production.example new file mode 100644 index 000000000..313246818 --- /dev/null +++ b/apps/skilltree/apps/web/.env.production.example @@ -0,0 +1,12 @@ +# SkillTree Web App - Production Environment Variables +# Copy this file to .env.production and fill in the values + +# ============================================================================= +# REQUIRED +# ============================================================================= + +# Backend API URL +PUBLIC_BACKEND_URL=https://skilltree-api.mana.how + +# Mana Core Auth URL for authentication +PUBLIC_MANA_CORE_AUTH_URL=https://auth.mana.how diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json index 66774ff58..669146ff4 100644 --- a/apps/skilltree/apps/web/package.json +++ b/apps/skilltree/apps/web/package.json @@ -25,6 +25,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-auth": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-utils": "workspace:*", diff --git a/apps/skilltree/apps/web/src/hooks.server.ts b/apps/skilltree/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..a450239b2 --- /dev/null +++ b/apps/skilltree/apps/web/src/hooks.server.ts @@ -0,0 +1,24 @@ +/** + * Server Hooks for SvelteKit + * - Injects runtime environment variables for client-side use + * - Auth is handled client-side via Mana Core Auth + */ + +import type { Handle } from '@sveltejs/kit'; + +const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; +const PUBLIC_BACKEND_URL_CLIENT = + process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || ''; + +export const handle: Handle = async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + const envScript = ``; + return html.replace('', `${envScript}`); + }, + }); +}; diff --git a/apps/skilltree/apps/web/src/lib/api/activities.ts b/apps/skilltree/apps/web/src/lib/api/activities.ts new file mode 100644 index 000000000..d2cca8c5f --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/activities.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client'; +import type { Activity } from '$lib/types'; + +interface ActivitiesResponse { + activities: Activity[]; +} + +export async function getActivities(skillId?: string, limit?: number): Promise { + const params = new URLSearchParams(); + if (skillId) params.append('skillId', skillId); + if (limit) params.append('limit', String(limit)); + const queryString = params.toString() ? `?${params.toString()}` : ''; + const response = await apiClient.get(`/api/v1/activities${queryString}`); + return response.activities; +} + +export async function getRecentActivities(limit = 10): Promise { + return getActivities(undefined, limit); +} + +export async function getSkillActivities(skillId: string): Promise { + return getActivities(skillId); +} diff --git a/apps/skilltree/apps/web/src/lib/api/client.ts b/apps/skilltree/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..29bcbb7f4 --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/client.ts @@ -0,0 +1,98 @@ +import { browser } from '$app/environment'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +interface ApiOptions { + method?: string; + body?: unknown; + headers?: Record; +} + +interface ApiError { + message: string; + statusCode: number; +} + +/** + * Get the backend URL, preferring runtime-injected value in browser + * This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime + */ +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (runtimeUrl) { + return runtimeUrl; + } + } + return PUBLIC_BACKEND_URL || 'http://localhost:3024'; +} + +class ApiClient { + private accessToken: string | null = null; + + private get baseUrl(): string { + return getBackendUrl(); + } + + setAccessToken(token: string | null) { + this.accessToken = token; + } + + getAccessToken(): string | null { + return this.accessToken; + } + + async fetch(endpoint: string, options: ApiOptions = {}): Promise { + const { method = 'GET', body, headers = {} } = options; + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...headers, + }; + + if (this.accessToken) { + requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method, + headers: requestHeaders, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = 'An error occurred'; + try { + const errorData = (await response.json()) as ApiError; + errorMessage = errorData.message || errorMessage; + } catch { + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; + } + + get(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'GET', headers }); + } + + post(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'POST', body, headers }); + } + + put(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'PUT', body, headers }); + } + + delete(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'DELETE', headers }); + } +} + +export const apiClient = new ApiClient(); diff --git a/apps/skilltree/apps/web/src/lib/api/skills.ts b/apps/skilltree/apps/web/src/lib/api/skills.ts new file mode 100644 index 000000000..6324a51cf --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/api/skills.ts @@ -0,0 +1,80 @@ +import { apiClient } from './client'; +import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; + +interface CreateSkillDto { + name: string; + description?: string; + branch: SkillBranch; + parentId?: string; + icon?: string; + color?: string; +} + +interface UpdateSkillDto { + name?: string; + description?: string; + branch?: SkillBranch; + parentId?: string | null; + icon?: string; + color?: string | null; +} + +interface AddXpDto { + xp: number; + description: string; + duration?: number; +} + +interface AddXpResponse { + skill: Skill; + activity: Activity; + leveledUp: boolean; + previousLevel: number; + newLevel: number; +} + +interface SkillsResponse { + skills: Skill[]; +} + +interface SkillResponse { + skill: Skill; +} + +interface StatsResponse { + stats: UserStats; +} + +export async function getSkills(branch?: SkillBranch): Promise { + const queryString = branch ? `?branch=${branch}` : ''; + const response = await apiClient.get(`/api/v1/skills${queryString}`); + return response.skills; +} + +export async function getSkill(id: string): Promise { + const response = await apiClient.get(`/api/v1/skills/${id}`); + return response.skill; +} + +export async function createSkill(data: CreateSkillDto): Promise { + const response = await apiClient.post('/api/v1/skills', data); + return response.skill; +} + +export async function updateSkill(id: string, data: UpdateSkillDto): Promise { + const response = await apiClient.put(`/api/v1/skills/${id}`, data); + return response.skill; +} + +export async function deleteSkill(id: string): Promise { + await apiClient.delete(`/api/v1/skills/${id}`); +} + +export async function addXp(skillId: string, data: AddXpDto): Promise { + return await apiClient.post(`/api/v1/skills/${skillId}/xp`, data); +} + +export async function getStats(): Promise { + const response = await apiClient.get('/api/v1/skills/stats'); + return response.stats; +} diff --git a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..c922cd75a --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,212 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth, type UserData } from '@manacore/shared-auth'; +import { apiClient } from '$lib/api/client'; + +const DEV_AUTH_URL = 'http://localhost:3001'; +const DEV_BACKEND_URL = 'http://localhost:3024'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + if (injectedUrl) return injectedUrl; + return import.meta.env.DEV ? DEV_AUTH_URL : ''; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL; +} + +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (injectedUrl) return injectedUrl; + return import.meta.env.DEV ? DEV_BACKEND_URL : ''; + } + return process.env.PUBLIC_BACKEND_URL || DEV_BACKEND_URL; +} + +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + const authenticated = await authService.isAuthenticated(); + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + + const token = await authService.getAppToken(); + if (token) { + apiClient.setAccessToken(token); + } + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + const token = await authService.getAppToken(); + if (token) { + apiClient.setAccessToken(token); + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, undefined, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + apiClient.setAccessToken(null); + return; + } + + try { + await authService.signOut(); + user = null; + apiClient.setAccessToken(null); + } catch (error) { + console.error('Sign out error:', error); + user = null; + apiClient.setAccessToken(null); + } + }, + + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.forgotPassword(email); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async getAccessToken() { + const authService = getAuthService(); + if (!authService) { + return null; + } + return await authService.getAppToken(); + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, +}; diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts index c5a5c0e96..cc79b6acb 100644 --- a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -1,11 +1,9 @@ import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; -import { - calculateLevel, - createDefaultSkill, - createActivity, - BRANCH_INFO, -} from '$lib/types'; +import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types'; import * as storage from '$lib/services/storage'; +import * as skillsApi from '$lib/api/skills'; +import * as activitiesApi from '$lib/api/activities'; +import { authStore } from './auth.svelte'; // Reactive state using Svelte 5 runes let skills = $state([]); @@ -19,6 +17,7 @@ let userStats = $state({ }); let isLoading = $state(true); let initialized = $state(false); +let useApi = $state(false); // Derived values const skillsByBranch = $derived(() => { @@ -42,13 +41,14 @@ const topSkills = $derived(() => { }); const recentActivities = $derived(() => { - return [...activities].sort((a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ).slice(0, 10); + return [...activities] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 10); }); const branchStats = $derived(() => { - const stats: Record = {} as any; + const stats: Record = + {} as Record; for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) { const branchSkills = skills.filter((s) => s.branch === branch); stats[branch] = { @@ -69,81 +69,171 @@ async function initialize() { isLoading = true; try { - const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ - storage.getAllSkills(), - storage.getAllActivities(), - storage.getUserStats(), - ]); - skills = loadedSkills; - activities = loadedActivities; - userStats = loadedStats; + // Check if user is authenticated + if (authStore.isAuthenticated) { + useApi = true; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + skillsApi.getSkills(), + activitiesApi.getRecentActivities(50), + skillsApi.getStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } else { + // Fallback to IndexedDB for offline/unauthenticated use + useApi = false; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + storage.getAllSkills(), + storage.getAllActivities(), + storage.getUserStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } initialized = true; } catch (error) { console.error('Failed to initialize skills store:', error); + // On error, try IndexedDB as fallback + if (useApi) { + try { + useApi = false; + const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([ + storage.getAllSkills(), + storage.getAllActivities(), + storage.getUserStats(), + ]); + skills = loadedSkills; + activities = loadedActivities; + userStats = loadedStats; + } catch (fallbackError) { + console.error('Fallback to IndexedDB also failed:', fallbackError); + } + } } finally { isLoading = false; } } async function addSkill(data: Partial): Promise { - const skill = createDefaultSkill(data); - await storage.saveSkill(skill); - skills = [...skills, skill]; - await updateStats(); - return skill; + if (useApi && authStore.isAuthenticated) { + const skill = await skillsApi.createSkill({ + name: data.name || '', + description: data.description, + branch: data.branch || 'custom', + parentId: data.parentId ?? undefined, + icon: data.icon, + color: data.color ?? undefined, + }); + skills = [...skills, skill]; + await updateStats(); + return skill; + } else { + const skill = createDefaultSkill(data); + await storage.saveSkill(skill); + skills = [...skills, skill]; + await updateStats(); + return skill; + } } async function updateSkill(id: string, updates: Partial): Promise { const index = skills.findIndex((s) => s.id === id); if (index === -1) return; - const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; - await storage.saveSkill(updatedSkill); - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + if (useApi && authStore.isAuthenticated) { + const skill = await skillsApi.updateSkill(id, { + name: updates.name, + description: updates.description, + branch: updates.branch, + parentId: updates.parentId, + icon: updates.icon, + color: updates.color, + }); + skills = [...skills.slice(0, index), skill, ...skills.slice(index + 1)]; + } else { + const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; + await storage.saveSkill(updatedSkill); + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + } await updateStats(); } async function deleteSkill(id: string): Promise { - await storage.deleteSkill(id); + if (useApi && authStore.isAuthenticated) { + await skillsApi.deleteSkill(id); + } else { + await storage.deleteSkill(id); + } skills = skills.filter((s) => s.id !== id); activities = activities.filter((a) => a.skillId !== id); await updateStats(); } -async function addXp(skillId: string, xp: number, description: string, duration?: number): Promise<{ leveledUp: boolean; newLevel: number }> { +async function addXp( + skillId: string, + xp: number, + description: string, + duration?: number +): Promise<{ leveledUp: boolean; newLevel: number }> { const index = skills.findIndex((s) => s.id === skillId); if (index === -1) return { leveledUp: false, newLevel: 0 }; - const skill = skills[index]; - const newTotalXp = skill.totalXp + xp; - const newCurrentXp = skill.currentXp + xp; - const newLevel = calculateLevel(newTotalXp); - const leveledUp = newLevel > skill.level; + if (useApi && authStore.isAuthenticated) { + const result = await skillsApi.addXp(skillId, { xp, description, duration }); + skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)]; + activities = [...activities, result.activity]; + await updateStats(); + return { leveledUp: result.leveledUp, newLevel: result.newLevel }; + } else { + const skill = skills[index]; + const newTotalXp = skill.totalXp + xp; + const newCurrentXp = skill.currentXp + xp; + const newLevel = calculateLevel(newTotalXp); + const leveledUp = newLevel > skill.level; - const updatedSkill: Skill = { - ...skill, - totalXp: newTotalXp, - currentXp: newCurrentXp, - level: newLevel, - updatedAt: new Date().toISOString(), - }; + const updatedSkill: Skill = { + ...skill, + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + updatedAt: new Date().toISOString(), + }; - const activity = createActivity(skillId, xp, description, duration); + const activity = createActivity(skillId, xp, description, duration); - await Promise.all([ - storage.saveSkill(updatedSkill), - storage.saveActivity(activity), - ]); + await Promise.all([storage.saveSkill(updatedSkill), storage.saveActivity(activity)]); - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - activities = [...activities, activity]; - await updateStats(); + skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; + activities = [...activities, activity]; + await updateStats(); - return { leveledUp, newLevel }; + return { leveledUp, newLevel }; + } } async function updateStats(): Promise { - userStats = await storage.recalculateStats(); + if (useApi && authStore.isAuthenticated) { + try { + userStats = await skillsApi.getStats(); + } catch { + // Calculate locally as fallback + userStats = calculateLocalStats(); + } + } else { + userStats = await storage.recalculateStats(); + } +} + +function calculateLocalStats(): UserStats { + return { + totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), + totalSkills: skills.length, + highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), + streakDays: 0, + lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null, + }; } function getSkill(id: string): Skill | undefined { @@ -154,19 +244,56 @@ function getSkillActivities(skillId: string): Activity[] { return activities.filter((a) => a.skillId === skillId); } +// Reinitialize when auth state changes +async function reinitialize() { + initialized = false; + skills = []; + activities = []; + userStats = { + totalXp: 0, + totalSkills: 0, + highestLevel: 0, + streakDays: 0, + lastActivityDate: null, + }; + await initialize(); +} + // Export store as object with getters for reactive access export const skillStore = { - get skills() { return skills; }, - get activities() { return activities; }, - get userStats() { return userStats; }, - get isLoading() { return isLoading; }, - get initialized() { return initialized; }, - get skillsByBranch() { return skillsByBranch; }, - get topSkills() { return topSkills; }, - get recentActivities() { return recentActivities; }, - get branchStats() { return branchStats; }, + get skills() { + return skills; + }, + get activities() { + return activities; + }, + get userStats() { + return userStats; + }, + get isLoading() { + return isLoading; + }, + get initialized() { + return initialized; + }, + get skillsByBranch() { + return skillsByBranch; + }, + get topSkills() { + return topSkills; + }, + get recentActivities() { + return recentActivities; + }, + get branchStats() { + return branchStats; + }, + get useApi() { + return useApi; + }, initialize, + reinitialize, addSkill, updateSkill, deleteSkill,