feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit f935754df4
120 changed files with 21772 additions and 90 deletions

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { z } from 'zod';
// Props
interface Props {
isLoading?: boolean;
errorMessage?: string | null;
}
let { isLoading = false, errorMessage = null }: Props = $props();
// Event dispatcher
const dispatch = createEventDispatcher<{
login: { email: string; password: string };
}>();
// Validation schema
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
// Form data - use $state for reactivity
let formData: LoginFormData = $state({
email: '',
password: '',
});
// Validation errors - use $state for reactivity
let errors: Partial<Record<keyof LoginFormData, string>> = $state({});
// Validate form
function validateForm(): boolean {
errors = {};
try {
loginSchema.parse(formData);
return true;
} catch (err) {
if (err instanceof z.ZodError) {
err.errors.forEach((error) => {
const field = error.path[0] as keyof LoginFormData;
errors[field] = error.message;
});
}
return false;
}
}
// Handle form submission
function handleSubmit(event: Event) {
event.preventDefault();
if (validateForm()) {
dispatch('login', formData);
}
}
</script>
<form class="space-y-4" onsubmit={handleSubmit}>
{#if errorMessage}
<div class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{errorMessage}</span>
</div>
{/if}
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email</span>
</label>
<input
type="email"
id="email"
class="input input-bordered w-full"
class:input-error={errors.email}
placeholder="admin@example.com"
bind:value={formData.email}
disabled={isLoading}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{#if errors.email}
<span id="email-error" class="label-text-alt text-error" data-testid="email-error">{errors.email}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="password">
<span class="label-text">Password</span>
</label>
<input
type="password"
id="password"
class="input input-bordered w-full"
class:input-error={errors.password}
placeholder="••••••••"
bind:value={formData.password}
disabled={isLoading}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{#if errors.password}
<span id="password-error" class="label-text-alt text-error" data-testid="password-error">{errors.password}</span>
{/if}
</div>
<button
type="submit"
class="btn btn-primary w-full"
disabled={isLoading}
>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
Logging in...
{:else}
Login
{/if}
</button>
</form>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { user, logout } from '$lib/stores/auth';
import { goto } from '$app/navigation';
// Get user from store using $derived for reactivity
let currentUser = $derived($user);
async function handleLogout() {
await logout();
goto('/login');
}
</script>
<nav class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/" class="btn btn-ghost normal-case text-xl">Headroom</a>
</div>
<div class="flex-none gap-2">
{#if currentUser}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full bg-primary">
<span class="text-xl">{currentUser.email?.charAt(0).toUpperCase()}</span>
</div>
</label>
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">
<li>
<a href="/dashboard" class="justify-between">
Dashboard
</a>
</li>
{#if currentUser.role === 'superuser' || currentUser.role === 'manager'}
<li><a href="/team-members">Team Members</a></li>
<li><a href="/projects">Projects</a></li>
{/if}
<li><a href="/reports">Reports</a></li>
<div class="divider"></div>
<li>
<button onclick={handleLogout} class="text-error">Logout</button>
</li>
</ul>
</div>
{:else}
<a href="/login" class="btn btn-primary btn-sm">Login</a>
{/if}
</div>
</nav>

View File

@@ -0,0 +1,224 @@
/**
* API Client Service
*
* Fetch wrapper with JWT token handling, automatic refresh,
* and standardized error handling for the Headroom API.
*/
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
// Token storage keys
const ACCESS_TOKEN_KEY = 'headroom_access_token';
const REFRESH_TOKEN_KEY = 'headroom_refresh_token';
// Token management
export function getAccessToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens(): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
// API Error type
export class ApiError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
// Queue for requests waiting for token refresh
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
function subscribeTokenRefresh(callback: (token: string) => void): void {
refreshSubscribers.push(callback);
}
function onTokenRefreshed(newToken: string): void {
refreshSubscribers.forEach((callback) => {
callback(newToken);
});
refreshSubscribers = [];
}
// Refresh token function
async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
throw new Error('Token refresh failed');
}
const data = await response.json() as { access_token: string; refresh_token: string };
setTokens(data.access_token, data.refresh_token);
return data.access_token;
}
// API request options type
interface ApiRequestOptions {
method?: string;
headers?: Record<string, string>;
body?: unknown;
}
// Main API request function
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
// Prepare headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
// Add auth token if available
const token = getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Prepare request options
const requestOptions: RequestInit = {
...options,
headers,
};
// Handle request body
if (options.body && typeof options.body === 'object') {
requestOptions.body = JSON.stringify(options.body);
}
try {
let response = await fetch(url, requestOptions);
// Handle token expiration
if (response.status === 401 && token) {
if (!isRefreshing) {
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
isRefreshing = false;
onTokenRefreshed(newToken);
} catch (error) {
isRefreshing = false;
refreshSubscribers = [];
clearTokens();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('auth:logout'));
}
throw new ApiError('Session expired. Please log in again.', 401, null);
}
}
// Wait for token refresh and retry request
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken: string) => {
requestOptions.headers = {
...requestOptions.headers,
'Authorization': `Bearer ${newToken}`,
};
fetch(url, requestOptions)
.then((res) => handleResponse<T>(res))
.then(resolve)
.catch(reject);
});
});
}
return handleResponse<T>(response);
} catch (error) {
throw error;
}
}
// Handle API response
async function handleResponse<T>(response: Response): Promise<T> {
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type');
const isJson = contentType && contentType.includes('application/json');
const data = isJson ? await response.json() : await response.text();
if (!response.ok) {
const errorData = typeof data === 'object' ? data : { message: data };
const message = (errorData as { message?: string }).message || 'API request failed';
throw new ApiError(message, response.status, errorData);
}
return data as T;
}
// Convenience methods
export const api = {
get: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'POST', body }),
put: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'PUT', body }),
patch: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'PATCH', body }),
delete: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
};
// Login credentials type
interface LoginCredentials {
email: string;
password: string;
}
// Login response type
interface LoginResponse {
access_token: string;
refresh_token: string;
user: {
id: string;
email: string;
role: string;
};
}
// Auth-specific API methods
export const authApi = {
login: (credentials: LoginCredentials) =>
api.post<LoginResponse>('/auth/login', credentials),
logout: () => api.post<void>('/auth/logout'),
refresh: () => api.post<LoginResponse>('/auth/refresh', { refresh_token: getRefreshToken() }),
};
export default api;

View File

@@ -0,0 +1,191 @@
/**
* Auth Store
*
* Svelte store for user authentication state, token management,
* and user profile information.
*/
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api.js';
// User type
export interface User {
id: string;
email: string;
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
}
// Auth state type
export interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
// User store
function createUserStore() {
const { subscribe, set, update } = writable<User | null>(null);
return {
subscribe,
set,
update,
clear: () => set(null),
};
}
export const user = createUserStore();
// Authentication state store
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
isLoading: false,
error: null,
});
return {
subscribe,
set,
update,
setLoading: (loading: boolean) => update((state) => ({ ...state, isLoading: loading })),
setError: (error: string | null) => update((state) => ({ ...state, error })),
clearError: () => update((state) => ({ ...state, error: null })),
setAuthenticated: (authenticated: boolean) => update((state) => ({ ...state, isAuthenticated: authenticated })),
};
}
export const auth = createAuthStore();
// Derived store to check if user is authenticated
export const isAuthenticated = derived(
[user, auth],
([$user, $auth]) => $user !== null && $auth.isAuthenticated
);
// Derived store to get 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;
const token = getAccessToken();
if (token) {
auth.setAuthenticated(true);
// Optionally fetch user profile here
}
}
// Login credentials type
interface LoginCredentials {
email: string;
password: string;
}
// Login result type
interface LoginResult {
success: boolean;
user?: User;
error?: string;
}
// Login function
export async function login(credentials: LoginCredentials): Promise<LoginResult> {
auth.setLoading(true);
auth.clearError();
try {
const response = await authApi.login(credentials);
if (response.access_token && response.refresh_token) {
setTokens(response.access_token, response.refresh_token);
user.set(response.user || null);
auth.setAuthenticated(true);
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);
return { success: false, error: errorMessage };
} finally {
auth.setLoading(false);
}
}
// Logout function
export async function logout(): Promise<void> {
auth.setLoading(true);
try {
await authApi.logout();
} catch (error) {
console.error('Logout API error:', error);
} finally {
clearTokens();
user.clear();
auth.setAuthenticated(false);
auth.setLoading(false);
}
}
// Check authentication status
export async function checkAuth(): Promise<boolean> {
if (!browser) return false;
const token = getAccessToken();
if (!token) {
auth.setAuthenticated(false);
user.clear();
return false;
}
auth.setAuthenticated(true);
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;
}
export function isSuperuser(): boolean {
return hasRole('superuser');
}
export function isManager(): boolean {
return hasRole('manager');
}
export function isDeveloper(): boolean {
return hasRole('developer');
}
export function isTopBrass(): boolean {
return hasRole('top_brass');
}
// Permission check (can be expanded based on requirements)
export function canManageTeamMembers(): boolean {
return isSuperuser() || isManager();
}
export function canManageProjects(): boolean {
return isSuperuser() || isManager();
}
export function canViewReports(): boolean {
return isSuperuser() || isManager() || isTopBrass();
}
export function canLogActuals(): boolean {
return isSuperuser() || isDeveloper();
}