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:
2
frontend/src/app.css
Normal file
2
frontend/src/app.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "daisyui";
|
||||
17
frontend/src/app.d.ts
vendored
Normal file
17
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
134
frontend/src/lib/components/Auth/LoginForm.svelte
Normal file
134
frontend/src/lib/components/Auth/LoginForm.svelte
Normal 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>
|
||||
48
frontend/src/lib/components/Navigation.svelte
Normal file
48
frontend/src/lib/components/Navigation.svelte
Normal 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>
|
||||
224
frontend/src/lib/services/api.ts
Normal file
224
frontend/src/lib/services/api.ts
Normal 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;
|
||||
191
frontend/src/lib/stores/auth.ts
Normal file
191
frontend/src/lib/stores/auth.ts
Normal 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();
|
||||
}
|
||||
17
frontend/src/routes/+layout.svelte
Normal file
17
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { initAuth } from '$lib/stores/auth';
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
initAuth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<Navigation />
|
||||
|
||||
<main class="container mx-auto px-4 py-6">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
18
frontend/src/routes/+page.svelte
Normal file
18
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated } from '$lib/stores/auth';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Redirect based on auth state
|
||||
$: if (browser) {
|
||||
if ($isAuthenticated) {
|
||||
goto('/dashboard');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="loading loading-spinner loading-lg text-primary"></div>
|
||||
</div>
|
||||
18
frontend/src/routes/dashboard/+layout.ts
Normal file
18
frontend/src/routes/dashboard/+layout.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getAccessToken } from '$lib/services/api';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async () => {
|
||||
// Check authentication on client side using localStorage (source of truth)
|
||||
if (browser) {
|
||||
const token = getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
goto('/login');
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: true };
|
||||
};
|
||||
51
frontend/src/routes/dashboard/+page.svelte
Normal file
51
frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { user, logout } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - Headroom</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-3xl mb-4">Welcome to Headroom! 👋</h1>
|
||||
|
||||
{#if $user}
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>Logged in as {$user.email} ({$user.role})</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
You have successfully authenticated. This is a protected dashboard page
|
||||
that requires a valid JWT token to access.
|
||||
</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">Authentication Features Implemented:</h2>
|
||||
<ul class="list-disc list-inside space-y-2 mb-6">
|
||||
<li>✅ JWT Token Authentication</li>
|
||||
<li>✅ Token Auto-refresh on 401</li>
|
||||
<li>✅ Protected Route Guards</li>
|
||||
<li>✅ Form Validation with Zod</li>
|
||||
<li>✅ Role-based Access Control</li>
|
||||
<li>✅ Redis Token Storage</li>
|
||||
</ul>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-error" on:click={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
45
frontend/src/routes/login/+page.svelte
Normal file
45
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import LoginForm from '$lib/components/Auth/LoginForm.svelte';
|
||||
import { login, auth } from '$lib/stores/auth';
|
||||
|
||||
async function handleLogin(event: CustomEvent<{ email: string; password: string }>) {
|
||||
const { email, password } = event.detail;
|
||||
const result = await login({ email, password });
|
||||
|
||||
if (result.success) {
|
||||
goto('/dashboard');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Headroom</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl text-center justify-center mb-6">
|
||||
Welcome to Headroom
|
||||
</h1>
|
||||
|
||||
<p class="text-center text-base-content/70 mb-6">
|
||||
Sign in to access your dashboard
|
||||
</p>
|
||||
|
||||
<LoginForm
|
||||
on:login={handleLogin}
|
||||
isLoading={$auth.isLoading}
|
||||
errorMessage={$auth.error}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="text-center text-sm text-base-content/60">
|
||||
<p>Demo credentials:</p>
|
||||
<p class="font-mono mt-1">admin@example.com / password</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1
frontend/src/routes/login/+page.ts
Normal file
1
frontend/src/routes/login/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user