Refactoring, regression testing until Phase 1 end.

This commit is contained in:
2026-02-18 20:48:25 -05:00
parent 5422a324fc
commit 249e0ade8e
26 changed files with 1639 additions and 253 deletions

View File

@@ -246,6 +246,7 @@ interface LoginResponse {
refresh_token: string;
user: {
id: string;
name: string;
email: string;
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
};

View File

@@ -3,27 +3,84 @@
*
* Svelte store for user authentication state, token management,
* and user profile information.
*
* Features:
* - Centralized authentication state machine
* - Consistent error message handling
* - Persistent storage integration
* - Type-safe state transitions
*/
import { writable, derived } from 'svelte/store';
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api';
// User type
// ============================================================================
// Types
// ============================================================================
export type UserRole = 'superuser' | 'manager' | 'developer' | 'top_brass';
export interface User {
id: string;
name: string;
email: string;
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
role: UserRole;
}
// Auth state type
export type AuthStatus = 'idle' | 'loading' | 'authenticated' | 'unauthenticated' | 'error';
export interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
status: AuthStatus;
error: string | null;
lastErrorAt: number | null;
}
// User store
// ============================================================================
// Error Messages
// ============================================================================
const ERROR_MESSAGES = {
INVALID_CREDENTIALS: 'Invalid email or password. Please try again.',
NETWORK_ERROR: 'Unable to connect to the server. Please check your connection.',
SERVER_ERROR: 'An error occurred on the server. Please try again later.',
SESSION_EXPIRED: 'Your session has expired. Please log in again.',
UNAUTHORIZED: 'You are not authorized to access this resource.',
UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.',
VALIDATION_ERROR: 'Please check your input and try again.',
} as const;
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
// Map known error patterns to consistent messages
const message = error.message.toLowerCase();
if (message.includes('invalid credentials') || message.includes('unauthorized')) {
return ERROR_MESSAGES.INVALID_CREDENTIALS;
}
if (message.includes('network') || message.includes('fetch')) {
return ERROR_MESSAGES.NETWORK_ERROR;
}
if (message.includes('timeout') || message.includes('504') || message.includes('503')) {
return ERROR_MESSAGES.SERVER_ERROR;
}
if (message.includes('session') || message.includes('expired')) {
return ERROR_MESSAGES.SESSION_EXPIRED;
}
if (message.includes('validation') || message.includes('invalid')) {
return ERROR_MESSAGES.VALIDATION_ERROR;
}
return error.message;
}
return ERROR_MESSAGES.UNKNOWN_ERROR;
}
// ============================================================================
// Stores
// ============================================================================
function createUserStore() {
const { subscribe, set, update } = writable<User | null>(null);
@@ -37,64 +94,79 @@ function createUserStore() {
export const user = createUserStore();
// Authentication state store
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
isLoading: false,
status: 'idle',
error: null,
lastErrorAt: null,
});
return {
subscribe,
set,
update,
setLoading: (loading: boolean) => update((state) => ({ ...state, isLoading: loading })),
setError: (error: string | null) => update((state) => ({ ...state, error })),
// State transitions
setIdle: () => set({ status: 'idle', error: null, lastErrorAt: null }),
setLoading: () => update((state) => ({ ...state, status: 'loading', error: null })),
setAuthenticated: () => set({ status: 'authenticated', error: null, lastErrorAt: null }),
setUnauthenticated: () => set({ status: 'unauthenticated', error: null, lastErrorAt: null }),
setError: (error: unknown) => {
const errorMessage = getErrorMessage(error);
set({
status: 'error',
error: errorMessage,
lastErrorAt: Date.now()
});
},
// Utility
clearError: () => update((state) => ({ ...state, error: null })),
setAuthenticated: (authenticated: boolean) => update((state) => ({ ...state, isAuthenticated: authenticated })),
// Getters
getState: () => get({ subscribe }),
};
}
export const auth = createAuthStore();
// Derived store to check if user is authenticated
// ============================================================================
// Derived Stores
// ============================================================================
/** Check if user is authenticated */
export const isAuthenticated = derived(
[user, auth],
([$user, $auth]) => $user !== null && $auth.isAuthenticated
([$user, $auth]) => $user !== null && $auth.status === 'authenticated'
);
// Derived store to get user role
/** Get current user role */
export const userRole = derived(user, ($user) => $user?.role || null);
// Initialize auth state from localStorage (client-side only)
export function initAuth(): void {
if (!browser) return;
/** Check if auth is in loading state */
export const isLoading = derived(auth, ($auth) => $auth.status === 'loading');
const token = getAccessToken();
if (token) {
auth.setAuthenticated(true);
// Optionally fetch user profile here
}
}
/** Get current error message */
export const authError = derived(auth, ($auth) => $auth.error);
// Login credentials type
interface LoginCredentials {
// ============================================================================
// Actions
// ============================================================================
export interface LoginCredentials {
email: string;
password: string;
}
// Login result type
interface LoginResult {
export interface LoginResult {
success: boolean;
user?: User;
error?: string;
}
// Login function
/**
* Login action
*/
export async function login(credentials: LoginCredentials): Promise<LoginResult> {
auth.setLoading(true);
auth.clearError();
auth.setLoading();
try {
const response = await authApi.login(credentials);
@@ -102,23 +174,23 @@ export async function login(credentials: LoginCredentials): Promise<LoginResult>
if (response.access_token && response.refresh_token) {
setTokens(response.access_token, response.refresh_token);
user.set(response.user || null);
auth.setAuthenticated(true);
auth.setAuthenticated();
return { success: true, user: response.user };
} else {
throw new Error('Invalid response from server');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed';
auth.setError(errorMessage);
const errorMessage = getErrorMessage(error);
auth.setError(error);
return { success: false, error: errorMessage };
} finally {
auth.setLoading(false);
}
}
// Logout function
/**
* Logout action
*/
export async function logout(): Promise<void> {
auth.setLoading(true);
auth.setLoading();
try {
await authApi.logout();
@@ -127,53 +199,73 @@ export async function logout(): Promise<void> {
} finally {
clearTokens();
user.clear();
auth.setAuthenticated(false);
auth.setLoading(false);
auth.setUnauthenticated();
}
}
// Check authentication status
/**
* Check authentication status
*/
export async function checkAuth(): Promise<boolean> {
if (!browser) return false;
const token = getAccessToken();
if (!token) {
auth.setAuthenticated(false);
auth.setUnauthenticated();
user.clear();
return false;
}
auth.setAuthenticated(true);
// If we have a token but no user, we're in a "restoring" state
const currentUser = get(user);
if (!currentUser) {
// Token exists but user data is missing - try to restore from token
// For now, we mark as authenticated and let the app fetch user data
auth.setAuthenticated();
}
return true;
}
// Role check helpers
export function hasRole(role: string): boolean {
let currentRole: string | null = null;
const unsubscribe = userRole.subscribe((r) => {
currentRole = r;
});
unsubscribe();
return currentRole === role;
/**
* Initialize auth state from storage
*/
export function initAuth(): void {
if (!browser) return;
const token = getAccessToken();
if (token) {
auth.setAuthenticated();
} else {
auth.setUnauthenticated();
}
}
export function isSuperuser(): boolean {
return hasRole('superuser');
/**
* Clear any authentication error
*/
export function clearAuthError(): void {
auth.clearError();
}
export function isManager(): boolean {
return hasRole('manager');
// ============================================================================
// Role Helpers
// ============================================================================
export function hasRole(role: UserRole): boolean {
const currentUser = get(user);
return currentUser?.role === role;
}
export function isDeveloper(): boolean {
return hasRole('developer');
}
export const isSuperuser = () => hasRole('superuser');
export const isManager = () => hasRole('manager');
export const isDeveloper = () => hasRole('developer');
export const isTopBrass = () => hasRole('top_brass');
export function isTopBrass(): boolean {
return hasRole('top_brass');
}
// ============================================================================
// Permission Helpers
// ============================================================================
// Permission check (can be expanded based on requirements)
export function canManageTeamMembers(): boolean {
return isSuperuser() || isManager();
}

View File

@@ -1,11 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import LoginForm from '$lib/components/Auth/LoginForm.svelte';
import { login, auth } from '$lib/stores/auth';
import { login, isLoading, authError, clearAuthError } from '$lib/stores/auth';
import { LayoutDashboard } from 'lucide-svelte';
async function handleLogin(event: CustomEvent<{ email: string; password: string }>) {
const { email, password } = event.detail;
clearAuthError();
const result = await login({ email, password });
if (result.success) {
@@ -40,8 +42,8 @@
<LoginForm
on:login={handleLogin}
isLoading={$auth.isLoading}
errorMessage={$auth.error}
isLoading={$isLoading}
errorMessage={$authError}
/>
<div class="divider text-base-content/40 text-sm">Demo Access</div>

View File

@@ -42,7 +42,7 @@ test.describe('Authentication E2E', () => {
await page.click('button[type="submit"]');
// Should show error message
await expect(page.locator('text=Invalid credentials')).toBeVisible();
await expect(page.locator('text=Invalid email or password')).toBeVisible();
// Should stay on login page
await expect(page).toHaveURL('/login');