From ce962a58f378df5d306e298b69bdffc9785f5dc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:50:12 +0000 Subject: [PATCH 01/18] Initial plan From a683711b0f0efc04325fd92e942651337576dc33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:11:54 +0000 Subject: [PATCH 02/18] Add client-side API services and auth components with HttpOnly cookie security Co-authored-by: TheRealSeber <111927572+TheRealSeber@users.noreply.github.com> --- .env.example | 1 + CLIENT_API_MIGRATION.md | 393 ++++++++++++++++++ src/lib/auth/client-logout.ts | 32 ++ .../components/auth/ClientLoginForm.svelte | 130 ++++++ .../components/auth/ClientLogoutButton.svelte | 39 ++ src/lib/components/auth/index.ts | 2 + src/lib/services/ClientApiService.ts | 268 ++++++++++++ src/lib/services/ClientAuthService.ts | 108 +++++ src/lib/services/index.ts | 2 + 9 files changed, 975 insertions(+) create mode 100644 CLIENT_API_MIGRATION.md create mode 100644 src/lib/auth/client-logout.ts create mode 100644 src/lib/components/auth/ClientLoginForm.svelte create mode 100644 src/lib/components/auth/ClientLogoutButton.svelte create mode 100644 src/lib/components/auth/index.ts create mode 100644 src/lib/services/ClientApiService.ts create mode 100644 src/lib/services/ClientAuthService.ts diff --git a/.env.example b/.env.example index 48946fa..90e4355 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ BACKEND_API_URL=http://localhost:8000 +PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 diff --git a/CLIENT_API_MIGRATION.md b/CLIENT_API_MIGRATION.md new file mode 100644 index 0000000..c090fa2 --- /dev/null +++ b/CLIENT_API_MIGRATION.md @@ -0,0 +1,393 @@ +# Client API Migration Guide + +This document describes the new direct client-to-backend API integration for authentication and provides a reference for migrating other features. + +## Overview + +The new architecture allows direct communication between the browser and backend API without going through SvelteKit remote functions. This is implemented as a **proof of concept** for authentication, demonstrating the pattern for future migrations. + +## Security Model + +### Token Storage + +**Access Tokens**: Stored in HttpOnly cookies for maximum security +- **Set by**: Backend on login/register/refresh responses +- **Managed by**: Browser automatically sends with each request via `credentials: 'include'` +- **Security**: Protected from XSS attacks (JavaScript cannot access) +- **Cookie Attributes**: + - `HttpOnly`: true + - `Secure`: true (in production) + - `SameSite`: Strict (CSRF protection) + +**Refresh Tokens**: Also stored in HttpOnly cookies +- **Set by**: Backend on login/register responses +- **Used for**: Token refresh when access token expires +- **Security**: Same HttpOnly protection as access tokens + +### CSRF Protection + +- `SameSite=Strict` cookie attribute prevents CSRF attacks +- No additional CSRF tokens needed for same-site requests +- Cross-origin requests blocked by browser + +## Architecture + +### Services + +#### 1. ClientApiService (`src/lib/services/ClientApiService.ts`) + +Browser-only API client that: +- Makes HTTP requests to backend with `credentials: 'include'` +- Automatically handles 401 responses by refreshing tokens +- Implements race condition protection for concurrent refreshes +- Throws `ApiError` on failures + +```typescript +import { createClientApiClient } from '$lib/services'; + +const apiClient = createClientApiClient(); +// or with custom URL +const apiClient = createClientApiClient('https://api.example.com/api/v1'); +``` + +#### 2. ClientAuthService (`src/lib/services/ClientAuthService.ts`) + +Client-side authentication service providing: +- `login(credentials)` - Authenticate user +- `register(userData)` - Register new user +- `logout()` - Logout user +- `refreshToken()` - Manually refresh token + +```typescript +import { createClientApiClient, ClientAuthService } from '$lib/services'; + +const apiClient = createClientApiClient(); +const authService = new ClientAuthService(apiClient); + +const result = await authService.login({ + email: 'user@example.com', + password: 'password' +}); +``` + +### Components + +#### ClientLoginForm (`src/lib/components/auth/ClientLoginForm.svelte`) + +Reusable login form using client API: + +```svelte + + + +``` + +#### ClientLogoutButton (`src/lib/components/auth/ClientLogoutButton.svelte`) + +Logout button using client API: + +```svelte + + + + Logout + +``` + +### Helper Functions + +#### clientLogout (`src/lib/auth/client-logout.ts`) + +Standalone logout function: + +```typescript +import { clientLogout } from '$lib/auth/client-logout'; + +await clientLogout(); // Logs out and redirects to login +``` + +## Environment Variables + +Add to `.env` or `.env.local`: + +```env +# Server-side (private) +BACKEND_API_URL=http://localhost:8000 + +# Client-side (public) +PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 +``` + +## Token Refresh Flow + +1. **Request fails with 401**: ClientApiService intercepts +2. **Check if refreshing**: If already refreshing, wait for existing refresh +3. **Refresh token**: Call `/auth/refresh` with credentials +4. **Backend sets new cookie**: New access token in HttpOnly cookie +5. **Retry original request**: Automatically with new token +6. **Handle refresh failure**: Redirect to login page + +### Race Condition Handling + +The refresh mechanism uses promise-based queuing: + +```typescript +private isRefreshing = false; +private refreshPromise: Promise | null = null; + +private async refreshToken(): Promise { + if (this.isRefreshing && this.refreshPromise) { + return this.refreshPromise; // Wait for existing refresh + } + + this.isRefreshing = true; + this.refreshPromise = (async () => { + // Perform refresh... + })(); + + return this.refreshPromise; +} +``` + +## Migration Patterns + +### Pattern 1: Replace Remote Function with Client API + +**Before (Remote Function):** +```typescript +// login.remote.ts +import { form } from '$app/server'; +import { AuthService } from '$lib/services/AuthService'; + +export const login = form(Schema, async (data) => { + const apiClient = createApiClient(event.cookies); + const authService = new AuthService(apiClient); + const result = await authService.login(data); + // ... +}); +``` + +**After (Client API):** +```typescript +// Component +import { createClientApiClient, ClientAuthService } from '$lib/services'; + +const apiClient = createClientApiClient(); +const authService = new ClientAuthService(apiClient); + +async function handleLogin() { + const result = await authService.login({ + email, + password + }); + + if (result.success) { + await goto('/dashboard'); + } else { + toast.error(result.error); + } +} +``` + +### Pattern 2: Using Client API in Components + +```svelte + +``` + +## Comparison: Remote Functions vs Client API + +| Aspect | Remote Functions | Client API | +|--------|------------------|------------| +| **Execution** | Server-side | Browser-side | +| **Token Access** | Server cookies via `Cookies` API | HttpOnly cookies (automatic) | +| **Form Integration** | `.enhance()` for forms | Manual event handlers | +| **Type Safety** | Full TypeScript + Valibot validation | Client-side validation only | +| **Security** | Server-side validation | Client + server validation | +| **Use Case** | Traditional forms, SSR | Dynamic SPAs, client interactions | + +## When to Use Each Approach + +### Use Client API When: +- Building SPA-like experiences +- Need dynamic, interactive UI updates +- Want to avoid full page loads +- Implementing real-time features +- Building mobile-like web apps + +### Keep Remote Functions When: +- Traditional form submissions work well +- Need server-side validation before API call +- Want progressive enhancement +- SEO is critical (forms work without JS) + +## Example: Complete Login Page with Client API + +```svelte + + +
+ + + +
+``` + +## Testing + +### Manual Testing Checklist + +- [ ] Login with valid credentials +- [ ] Login with invalid credentials +- [ ] Register new user +- [ ] Logout functionality +- [ ] Token refresh on 401 +- [ ] Multiple concurrent 401s (race condition) +- [ ] Navigate after login +- [ ] Session persistence across page reloads +- [ ] Logout clears session + +### Backend Requirements + +The backend must: +1. Set HttpOnly cookies for access and refresh tokens +2. Include `SameSite=Strict` and `Secure` (in prod) attributes +3. Support `/auth/refresh` endpoint +4. Return 401 for expired access tokens +5. Clear cookies on `/auth/logout` + +## Future Migration Steps + +1. **Phase 1**: Authentication (✅ Completed - POC) +2. **Phase 2**: User profile management +3. **Phase 3**: Task/Contest submissions +4. **Phase 4**: Admin operations +5. **Phase 5**: Real-time features + +Each phase should: +- Create corresponding `Client*Service` class +- Maintain backward compatibility +- Document migration patterns +- Test thoroughly + +## Troubleshooting + +### Token Not Being Sent + +**Problem**: Requests not including cookies + +**Solution**: Ensure `credentials: 'include'` in fetch options + +### CORS Issues + +**Problem**: CORS errors in browser console + +**Solution**: Backend must set: +``` +Access-Control-Allow-Origin: +Access-Control-Allow-Credentials: true +``` + +### Token Refresh Loop + +**Problem**: Infinite refresh loop + +**Solution**: Check backend returns proper 401 and refresh endpoint works + +### Logout Not Clearing Session + +**Problem**: User still authenticated after logout + +**Solution**: Verify backend clears HttpOnly cookies on `/auth/logout` + +## Security Considerations + +1. **HttpOnly Cookies**: Prevent XSS token theft +2. **SameSite=Strict**: Prevent CSRF attacks +3. **Secure Flag**: HTTPS only in production +4. **Token Refresh**: Short-lived access tokens (e.g., 15 min) +5. **Backend Validation**: Always validate on backend +6. **CORS Configuration**: Restrict allowed origins +7. **Rate Limiting**: Implement on backend +8. **SSL/TLS**: Required in production + +## References + +- [SvelteKit Documentation](https://kit.svelte.dev/) +- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) +- [MDN: HttpOnly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) +- [MDN: SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) diff --git a/src/lib/auth/client-logout.ts b/src/lib/auth/client-logout.ts new file mode 100644 index 0000000..ad2ed63 --- /dev/null +++ b/src/lib/auth/client-logout.ts @@ -0,0 +1,32 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import { AppRoutes } from '$lib/routes'; +import { createClientApiClient, ClientAuthService } from '$lib/services'; + +/** + * Client-side logout function + * Can be called from any component in browser context + */ +export async function clientLogout(): Promise { + if (!browser) { + console.error('clientLogout can only be called in browser context'); + return; + } + + try { + const apiClient = createClientApiClient(); + const authService = new ClientAuthService(apiClient); + + const result = await authService.logout(); + + if (!result.success) { + console.error('Logout failed:', result.error); + } + } catch (error) { + console.error('Error during logout:', error); + } finally { + // Always redirect to login, even if the API call fails + // This ensures the user is logged out on the client side + await goto(AppRoutes.Login); + } +} diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte new file mode 100644 index 0000000..5a38d3f --- /dev/null +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -0,0 +1,130 @@ + + +
+
+ + + {#if errors.email} +

{errors.email}

+ {/if} +
+ +
+
+ + + {m.login_forgot_password()} + +
+ + {#if errors.password} +

{errors.password}

+ {/if} +
+ + +
diff --git a/src/lib/components/auth/ClientLogoutButton.svelte b/src/lib/components/auth/ClientLogoutButton.svelte new file mode 100644 index 0000000..60086a9 --- /dev/null +++ b/src/lib/components/auth/ClientLogoutButton.svelte @@ -0,0 +1,39 @@ + + + diff --git a/src/lib/components/auth/index.ts b/src/lib/components/auth/index.ts new file mode 100644 index 0000000..7625658 --- /dev/null +++ b/src/lib/components/auth/index.ts @@ -0,0 +1,2 @@ +export { default as ClientLoginForm } from './ClientLoginForm.svelte'; +export { default as ClientLogoutButton } from './ClientLogoutButton.svelte'; diff --git a/src/lib/services/ClientApiService.ts b/src/lib/services/ClientApiService.ts new file mode 100644 index 0000000..052c2ac --- /dev/null +++ b/src/lib/services/ClientApiService.ts @@ -0,0 +1,268 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import type { AuthTokenData } from '../dto/auth'; +import { RequestMethod, RequestContentType, type Request } from '../dto/request'; +import type { ApiResponse } from '../dto/response'; +import { isApiErrorResponse } from '../dto/error'; +import { AppRoutes } from '$lib/routes'; +import { ApiError } from './ApiService'; + +/** + * Client-side API service for browser contexts + * Uses HttpOnly cookies for secure token storage + * Tokens are automatically sent with requests via credentials: 'include' + */ +export class ClientApiService { + private baseUrl: string; + private isRefreshing = false; + private refreshPromise: Promise | null = null; + + constructor(baseUrl: string) { + if (!browser) { + throw new Error('ClientApiService can only be used in browser context'); + } + this.baseUrl = baseUrl; + } + + /** + * Refresh the access token using the refresh token cookie + * Handles race conditions by queuing concurrent refresh attempts + */ + private async refreshToken(): Promise { + // If already refreshing, wait for the existing refresh to complete + if (this.isRefreshing && this.refreshPromise) { + return this.refreshPromise; + } + + this.isRefreshing = true; + this.refreshPromise = (async () => { + try { + const response = await fetch(`${this.baseUrl}/auth/refresh`, { + method: 'POST', + credentials: 'include' // Send refresh token cookie + }); + + if (!response.ok) { + console.warn('Token refresh failed, redirecting to login.'); + // Clear any client state if needed + goto(AppRoutes.Login); + throw new Error('Token refresh failed'); + } + + // The backend will set the new access token as an HttpOnly cookie + // We don't need to manually handle it - it's automatic + const data: ApiResponse = await response.json(); + console.log('Token refreshed successfully'); + } finally { + this.isRefreshing = false; + this.refreshPromise = null; + } + })(); + + return this.refreshPromise; + } + + /** + * Make an HTTP request to the API + * Automatically includes credentials (cookies) and handles 401 errors + */ + private async request(request: Request): Promise { + const headers = new Headers(request.options?.headers); + + // Set content type if specified + if (request.contentType) { + headers.set('Content-Type', request.contentType); + } else if (request.body && !(request.body instanceof FormData)) { + headers.set('Content-Type', RequestContentType.Json); + } + + // Make the request with credentials to include HttpOnly cookies + let response = await fetch(`${this.baseUrl}${request.url}`, { + method: request.method, + body: request.body, + ...request.options, + headers, + credentials: 'include' // Critical: includes HttpOnly cookies + }); + + // Handle 401 Unauthorized - try to refresh token + if (response.status === 401 && !request.url.includes('/auth/login')) { + try { + await this.refreshToken(); + + // Retry the original request after refresh + response = await fetch(`${this.baseUrl}${request.url}`, { + method: request.method, + body: request.body, + ...request.options, + headers, + credentials: 'include' + }); + } catch (error) { + // If refresh fails, the user will be redirected to login + throw error; + } + } + + return response; + } + + /** + * Parse error response body + */ + private async parseErrorBody(response: Response): Promise { + const contentType = response.headers.get('Content-Type'); + const clonedResponse = response.clone(); + + if (contentType?.includes('application/json')) { + try { + const data = await clonedResponse.json(); + + if (isApiErrorResponse(data)) { + return data; + } + + console.warn('API returned JSON error response in unexpected format:', data); + return data; + } catch (error) { + console.error('Failed to parse JSON error response:', error); + } + } + + try { + return await response.text(); + } catch (error) { + console.error('Failed to parse error response as text:', error); + return null; + } + } + + async get(request: Omit): Promise { + const response = await this.request({ + ...request, + method: RequestMethod.GET + }); + + if (!response.ok) { + const body = await this.parseErrorBody(response); + const error = new ApiError( + response.status, + response.statusText, + request.url, + RequestMethod.GET, + body + ); + + console.error('API GET request failed:', error.toJSON()); + throw error; + } + + return response.json(); + } + + async post(request: Omit): Promise { + const response = await this.request({ + ...request, + method: RequestMethod.POST + }); + + if (!response.ok) { + const body = await this.parseErrorBody(response); + const error = new ApiError( + response.status, + response.statusText, + request.url, + RequestMethod.POST, + body + ); + + console.error('API POST request failed:', error.toJSON()); + throw error; + } + + return response.json(); + } + + async put(request: Omit): Promise { + const response = await this.request({ + ...request, + method: RequestMethod.PUT + }); + + if (!response.ok) { + const body = await this.parseErrorBody(response); + const error = new ApiError( + response.status, + response.statusText, + request.url, + RequestMethod.PUT, + body + ); + + console.error('API PUT request failed:', error.toJSON()); + throw error; + } + + return response.json(); + } + + async patch(request: Omit): Promise { + const response = await this.request({ + ...request, + method: RequestMethod.PATCH + }); + + if (!response.ok) { + const body = await this.parseErrorBody(response); + const error = new ApiError( + response.status, + response.statusText, + request.url, + RequestMethod.PATCH, + body + ); + + console.error('API PATCH request failed:', error.toJSON()); + throw error; + } + + return response.json(); + } + + async delete(request: Omit): Promise { + const response = await this.request({ + ...request, + method: RequestMethod.DELETE + }); + + if (!response.ok) { + const body = await this.parseErrorBody(response); + const error = new ApiError( + response.status, + response.statusText, + request.url, + RequestMethod.DELETE, + body + ); + + console.error('API DELETE request failed:', error.toJSON()); + throw error; + } + + return response.json(); + } +} + +/** + * Create a client-side API client for browser use + * Requires PUBLIC_BACKEND_API_URL environment variable + */ +export function createClientApiClient(baseUrl?: string): ClientApiService { + if (!browser) { + throw new Error('createClientApiClient can only be used in browser context'); + } + + // Use provided URL or fall back to public env variable + const apiUrl = baseUrl || import.meta.env.PUBLIC_BACKEND_API_URL || 'http://localhost:8000/api/v1'; + return new ClientApiService(apiUrl); +} diff --git a/src/lib/services/ClientAuthService.ts b/src/lib/services/ClientAuthService.ts new file mode 100644 index 0000000..2ba7adc --- /dev/null +++ b/src/lib/services/ClientAuthService.ts @@ -0,0 +1,108 @@ +import { ApiError } from './ApiService'; +import type { ClientApiService } from './ClientApiService'; +import type { AuthTokenData } from '../dto/auth'; +import type { UserLoginDto, UserRegisterDto } from '../dto/user'; +import { RequestContentType } from '../dto/request'; +import type { ApiResponse } from '../dto/response'; + +/** + * Client-side authentication service + * Uses ClientApiService for browser-based authentication + * All tokens are managed via HttpOnly cookies set by the backend + */ +export class ClientAuthService { + constructor(private apiClient: ClientApiService) {} + + async login( + body: UserLoginDto + ): Promise<{ success: boolean; status: number; data?: AuthTokenData; error?: string }> { + try { + const response = await this.apiClient.post>({ + url: '/auth/login', + body: JSON.stringify(body), + contentType: RequestContentType.Json + }); + + // Backend sets HttpOnly cookies for both access and refresh tokens + return { success: true, data: response.data, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async register( + body: UserRegisterDto + ): Promise<{ success: boolean; status: number; data?: AuthTokenData; error?: string }> { + try { + const response = await this.apiClient.post>({ + url: '/auth/register', + body: JSON.stringify(body), + contentType: RequestContentType.Json + }); + + // Backend sets HttpOnly cookies for both access and refresh tokens + return { success: true, data: response.data, status: 201 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async logout(): Promise<{ success: boolean; status: number; error?: string }> { + try { + await this.apiClient.post({ + url: '/auth/logout', + body: '' + }); + + // Backend clears HttpOnly cookies + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + /** + * Manually trigger a token refresh + * Useful for proactively refreshing tokens before they expire + */ + async refreshToken(): Promise<{ success: boolean; status: number; error?: string }> { + try { + await this.apiClient.post({ + url: '/auth/refresh', + body: '' + }); + + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 444f203..f70de17 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -1,6 +1,8 @@ export { ApiService, ApiError, createApiClient } from './ApiService'; +export { ClientApiService, createClientApiClient } from './ClientApiService'; export { AccessControlService } from './AccessControlService'; export { AuthService } from './AuthService'; +export { ClientAuthService } from './ClientAuthService'; export { ContestService, createContestService } from './ContestService'; export { ContestsManagementService, From b8c117c85383538396904adc225dd7c3daf3c1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:14:52 +0000 Subject: [PATCH 03/18] Add comprehensive documentation and usage examples for client API Co-authored-by: TheRealSeber <111927572+TheRealSeber@users.noreply.github.com> --- README.md | 115 +++- USAGE_EXAMPLES.md | 585 ++++++++++++++++++ .../components/auth/ClientLoginForm.svelte | 2 +- .../components/auth/ClientLogoutButton.svelte | 9 +- src/lib/services/ClientApiService.ts | 3 +- 5 files changed, 689 insertions(+), 25 deletions(-) create mode 100644 USAGE_EXAMPLES.md diff --git a/README.md b/README.md index 75842c4..7398d69 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,79 @@ -# sv +# Frontend -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +Frontend application for the programming contest platform built with SvelteKit and Svelte 5. -## Creating a project +## Documentation -If you're seeing this, you've probably already done this step. Congrats! +- [Remote Functions Explanation](./REMOTE_FUNCTIONS_EXPLANATION.md) - Legacy server-side remote functions +- [Client API Migration Guide](./CLIENT_API_MIGRATION.md) - **NEW**: Direct client-to-backend API integration +- [Client API Usage Examples](./USAGE_EXAMPLES.md) - **NEW**: Practical examples for using the client API -```sh -# create a new project in the current directory -npx sv create +## Architecture + +This application supports two architectural patterns: + +1. **Remote Functions (Legacy)**: Server-side form handling with SvelteKit's remote functions +2. **Client API (New)**: Direct browser-to-backend communication with HttpOnly cookies + +### Client API (Recommended for New Features) + +The new client API provides: +- Direct browser-to-backend communication +- HttpOnly cookie security for tokens +- Automatic token refresh with race condition protection +- SPA-like user experience +- Full TypeScript support + +See [CLIENT_API_MIGRATION.md](./CLIENT_API_MIGRATION.md) for details. + +## Quick Start -# create a new project in my-app -npx sv create my-app +```bash +# Install dependencies +pnpm install + +# Set up environment +cp .env.example .env +# Edit .env with your configuration + +# Run development server +pnpm dev + +# Type check +pnpm check + +# Build for production +pnpm build ``` -## Developing +## Environment Variables + +```env +# Server-side (private) +BACKEND_API_URL=http://localhost:8000 + +# Client-side (public) +PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 +``` -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +## Technologies + +- **Framework**: SvelteKit with Svelte 5 +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS 4 +- **i18n**: Paraglide (English & Polish) +- **Validation**: Valibot +- **Package Manager**: pnpm (required) + +## Development + +Once you've created a project and installed dependencies with `pnpm install`, start a development server: ```sh -npm run dev +pnpm dev # or start the server and open the app in a new browser tab -npm run dev -- --open +pnpm dev -- --open ``` ## Building @@ -30,9 +81,41 @@ npm run dev -- --open To create a production version of your app: ```sh -npm run build +pnpm build ``` -You can preview the production build with `npm run preview`. +You can preview the production build with `pnpm preview`. + +## Testing + +```sh +# Type checking +pnpm check + +# Watch mode +pnpm check:watch -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +# Linting +pnpm lint + +# Format code +pnpm format +``` + +## Project Structure + +``` +src/ +├── lib/ +│ ├── auth/ # Client-side auth utilities +│ ├── components/ # Reusable components +│ │ └── auth/ # Auth-specific components (NEW) +│ ├── dto/ # Data transfer objects +│ ├── services/ # API services +│ │ ├── ApiService.ts # Server-side API client +│ │ ├── ClientApiService.ts # Browser-side API client (NEW) +│ │ ├── AuthService.ts # Server-side auth +│ │ └── ClientAuthService.ts # Browser-side auth (NEW) +│ └── token.ts # Token management +└── routes/ # SvelteKit routes +``` diff --git a/USAGE_EXAMPLES.md b/USAGE_EXAMPLES.md new file mode 100644 index 0000000..33351c3 --- /dev/null +++ b/USAGE_EXAMPLES.md @@ -0,0 +1,585 @@ +# Client API Usage Examples + +This document provides practical examples of using the new client API for authentication and other features. + +## Example 1: Using ClientLoginForm Component + +The simplest way to add client-side login to a page: + +```svelte + + + +
+ + +
+
+ +
+ + + + {m.login_title()} + {m.login_subtitle()} + + + + + + + +
+
+``` + +## Example 2: Custom Login Form with Client API + +For more control over the form layout and behavior: + +```svelte + + +
+
+ + {#if errors.email} +

{errors.email}

+ {/if} +
+ +
+ + {#if errors.password} +

{errors.password}

+ {/if} +
+ + +
+``` + +## Example 3: Using ClientLogoutButton + +Replace remote function logout with client logout: + +```svelte + + + + + Logout + + + + + + {m.sidebar_logout()} + +``` + +## Example 4: Programmatic Logout + +For logout triggered from JavaScript logic: + +```svelte + +``` + +## Example 5: Registration Form + +```svelte + + +
+ + + + + + + + +
+``` + +## Example 6: Proactive Token Refresh + +Refresh token before it expires to maintain smooth UX: + +```svelte + +``` + +## Example 7: Handling Auth State in Layout + +Check auth status and display user info: + +```svelte + + +{#if user} + +{:else} + +{/if} +``` + +## Example 8: Protected API Calls + +Make authenticated requests to other endpoints: + +```svelte + + +{#if isLoading} +

Loading...

+{:else if userProfile} +
+

{userProfile.name} {userProfile.surname}

+

{userProfile.email}

+
+{:else} +

Failed to load profile

+{/if} +``` + +## Example 9: Form with Loading States + +Proper loading and error states: + +```svelte + + +
+ {#if error} +
+ {error} +
+ {/if} + + + + + + +
+``` + +## Example 10: Environment-Specific API URL + +Override API URL for different environments: + +```svelte + +``` + +## Best Practices + +### 1. Always Check Browser Context + +```typescript +const apiClient = browser ? createClientApiClient() : null; +``` + +### 2. Handle Service Unavailability + +```typescript +if (!authService) { + toast.error('Service not available'); + return; +} +``` + +### 3. Use Loading States + +```typescript +let isLoading = $state(false); + +async function doSomething() { + isLoading = true; + try { + // ... + } finally { + isLoading = false; + } +} +``` + +### 4. Proper Error Handling + +```typescript +try { + const result = await authService.login(data); + if (result.success) { + // Success + } else { + // Handle API error + toast.error(result.error); + } +} catch (error) { + // Handle unexpected error + console.error(error); + toast.error('Unexpected error'); +} +``` + +### 5. Use TypeScript Types + +```typescript +import type { UserLoginDto } from '$lib/dto/user'; +import type { ApiResponse } from '$lib/dto/response'; +``` + +## Migration Checklist + +When migrating a feature from remote functions to client API: + +- [ ] Create/use appropriate client service +- [ ] Handle browser context check +- [ ] Implement loading states +- [ ] Add error handling +- [ ] Test success path +- [ ] Test error paths +- [ ] Test loading states +- [ ] Update navigation/redirects +- [ ] Remove old remote function (if fully migrated) +- [ ] Update documentation + +## Common Pitfalls + +### ❌ Don't: Create client on server + +```typescript +// This will error! +const apiClient = createClientApiClient(); +``` + +### ✅ Do: Check browser context + +```typescript +const apiClient = browser ? createClientApiClient() : null; +``` + +### ❌ Don't: Forget loading states + +```typescript +// Bad UX - no feedback +await authService.login(data); +``` + +### ✅ Do: Show loading states + +```typescript +isSubmitting = true; +try { + await authService.login(data); +} finally { + isSubmitting = false; +} +``` + +### ❌ Don't: Ignore errors + +```typescript +// Silent failure +const result = await authService.login(data); +``` + +### ✅ Do: Handle errors properly + +```typescript +const result = await authService.login(data); +if (!result.success) { + toast.error(result.error); +} +``` + +## Next Steps + +After implementing authentication with client API: + +1. Test thoroughly with backend +2. Monitor for errors in production +3. Migrate other features incrementally +4. Update team documentation +5. Share learnings with team diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte index 5a38d3f..ea9446c 100644 --- a/src/lib/components/auth/ClientLoginForm.svelte +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -27,7 +27,7 @@ async function handleSubmit(event: Event) { event.preventDefault(); - + if (!authService) { toast.error('Authentication service not available'); return; diff --git a/src/lib/components/auth/ClientLogoutButton.svelte b/src/lib/components/auth/ClientLogoutButton.svelte index 60086a9..86318a3 100644 --- a/src/lib/components/auth/ClientLogoutButton.svelte +++ b/src/lib/components/auth/ClientLogoutButton.svelte @@ -12,7 +12,7 @@ async function handleLogout() { if (!browser) return; - + isLoading = true; try { await clientLogout(); @@ -25,12 +25,7 @@ } - - -``` - -## Testing - -### Manual Testing Checklist - -- [ ] Login with valid credentials -- [ ] Login with invalid credentials -- [ ] Register new user -- [ ] Logout functionality -- [ ] Token refresh on 401 -- [ ] Multiple concurrent 401s (race condition) -- [ ] Navigate after login -- [ ] Session persistence across page reloads -- [ ] Logout clears session - -### Backend Requirements - -The backend must: -1. Set HttpOnly cookies for access and refresh tokens -2. Include `SameSite=Strict` and `Secure` (in prod) attributes -3. Support `/auth/refresh` endpoint -4. Return 401 for expired access tokens -5. Clear cookies on `/auth/logout` - -## Future Migration Steps - -1. **Phase 1**: Authentication (✅ Completed - POC) -2. **Phase 2**: User profile management -3. **Phase 3**: Task/Contest submissions -4. **Phase 4**: Admin operations -5. **Phase 5**: Real-time features - -Each phase should: -- Create corresponding `Client*Service` class -- Maintain backward compatibility -- Document migration patterns -- Test thoroughly - -## Troubleshooting - -### Token Not Being Sent - -**Problem**: Requests not including cookies - -**Solution**: Ensure `credentials: 'include'` in fetch options - -### CORS Issues - -**Problem**: CORS errors in browser console - -**Solution**: Backend must set: -``` -Access-Control-Allow-Origin: -Access-Control-Allow-Credentials: true -``` - -### Token Refresh Loop - -**Problem**: Infinite refresh loop - -**Solution**: Check backend returns proper 401 and refresh endpoint works - -### Logout Not Clearing Session - -**Problem**: User still authenticated after logout - -**Solution**: Verify backend clears HttpOnly cookies on `/auth/logout` - -## Security Considerations - -1. **HttpOnly Cookies**: Prevent XSS token theft -2. **SameSite=Strict**: Prevent CSRF attacks -3. **Secure Flag**: HTTPS only in production -4. **Token Refresh**: Short-lived access tokens (e.g., 15 min) -5. **Backend Validation**: Always validate on backend -6. **CORS Configuration**: Restrict allowed origins -7. **Rate Limiting**: Implement on backend -8. **SSL/TLS**: Required in production - -## References - -- [SvelteKit Documentation](https://kit.svelte.dev/) -- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) -- [MDN: HttpOnly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) -- [MDN: SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 64651c3..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,473 +0,0 @@ -# Client API Implementation Summary - -## Overview - -Successfully implemented a proof-of-concept migration of authentication functionality from SvelteKit remote functions to a direct client-to-backend API model. This establishes the architectural pattern for future feature migrations. - -## What Was Built - -### Core Services - -#### 1. ClientApiService (`src/lib/services/ClientApiService.ts`) -- **Purpose**: Browser-only API client for direct backend communication -- **Features**: - - Automatic token refresh on 401 responses - - Race condition protection for concurrent refreshes - - Exclusion list for refresh (login, register) to prevent infinite loops - - HttpOnly cookie handling via `credentials: 'include'` - - Comprehensive error handling with `ApiError` - - Full TypeScript support - -#### 2. ClientAuthService (`src/lib/services/ClientAuthService.ts`) -- **Purpose**: Authentication operations using client API -- **Methods**: - - `login(credentials)` - User authentication - - `register(userData)` - User registration - - `logout()` - Session termination - - `refreshToken()` - Manual token refresh -- **Features**: Consistent error handling, typed responses - -### UI Components - -#### 1. ClientLoginForm (`src/lib/components/auth/ClientLoginForm.svelte`) -- **Purpose**: Reusable login form with client API -- **Features**: - - Svelte 5 runes (`$state`, `$props`, `$derived`) - - Client-side validation - - Loading states - - Error display - - Configurable redirect - - Browser context checks - -#### 2. ClientLogoutButton (`src/lib/components/auth/ClientLogoutButton.svelte`) -- **Purpose**: Reusable logout button -- **Features**: - - Customizable styling via props - - Loading state management - - Snippet support for custom content - -### Utilities - -#### clientLogout (`src/lib/auth/client-logout.ts`) -- **Purpose**: Programmatic logout helper -- **Features**: Error handling, automatic redirect, browser check - -## Security Model - -### Token Storage Strategy - -**HttpOnly Cookies for Everything:** -- ✅ Access tokens stored in HttpOnly cookies (backend-set) -- ✅ Refresh tokens stored in HttpOnly cookies (backend-set) -- ✅ Cookies automatically sent with `credentials: 'include'` -- ✅ JavaScript cannot access tokens (XSS protection) - -### Cookie Attributes (Backend Responsibility) - -``` -Set-Cookie: access_token=; - HttpOnly; - Secure; - SameSite=Strict; - Path=/; - Max-Age=900 -``` - -- **HttpOnly**: Prevents JavaScript access (XSS protection) -- **Secure**: HTTPS only in production -- **SameSite=Strict**: CSRF protection -- **Path=/**: Available to all routes -- **Max-Age**: Short-lived (e.g., 15 minutes for access token) - -### Token Refresh Flow - -```mermaid -sequenceDiagram - participant C as Client - participant API as ClientApiService - participant B as Backend - - C->>API: Request resource - API->>B: GET /resource (with cookies) - B->>API: 401 Unauthorized - API->>API: Check not login/register - API->>API: Start refresh if not already - API->>B: POST /auth/refresh (with refresh cookie) - B->>API: 200 OK + Set-Cookie: new access token - API->>B: Retry GET /resource (with new token) - B->>API: 200 OK + data - API->>C: Return data -``` - -### Race Condition Protection - -```typescript -private isRefreshing = false; -private refreshPromise: Promise | null = null; - -private async refreshToken(): Promise { - // If already refreshing, wait for existing refresh - if (this.isRefreshing && this.refreshPromise) { - return this.refreshPromise; - } - - this.isRefreshing = true; - this.refreshPromise = (async () => { - try { - // Perform refresh... - } finally { - this.isRefreshing = false; - this.refreshPromise = null; - } - })(); - - return this.refreshPromise; -} -``` - -**Benefits:** -- Multiple concurrent 401s share single refresh -- Prevents refresh token spam -- Queue mechanism ensures order -- Automatic cleanup after refresh - -## Documentation - -### 1. CLIENT_API_MIGRATION.md (11KB) -**Contents:** -- Architecture overview -- Security model explanation -- Token refresh flow details -- Migration patterns and examples -- Comparison with remote functions -- Troubleshooting guide -- Security considerations - -### 2. USAGE_EXAMPLES.md (14KB) -**Contents:** -- 10 practical code examples -- Best practices -- Common pitfalls and solutions -- Migration checklist -- Component usage patterns - -### 3. Updated README.md -**Contents:** -- Quick start guide -- Environment variables -- Project structure -- Links to detailed docs - -## Code Quality - -### Reviews Completed -- ✅ Automated code review -- ✅ Security scan (CodeQL) -- ✅ Type checking -- ✅ Code formatting - -### Metrics -- **Files Created**: 9 -- **Lines of Code**: ~1,000 -- **Documentation**: ~25KB -- **Type Safety**: 100% TypeScript -- **Security Issues**: 0 - -## Environment Configuration - -### Required Variables - -```env -# Server-side (private) - For SSR API calls -BACKEND_API_URL=http://localhost:8000 - -# Client-side (public) - For browser API calls -PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 -``` - -### Usage in Code - -```typescript -// Server-side (ApiService) -import { env } from '$env/dynamic/private'; -const baseUrl = env.BACKEND_API_URL; - -// Client-side (ClientApiService) -const baseUrl = import.meta.env.PUBLIC_BACKEND_API_URL; -``` - -## Backend Requirements - -For this implementation to work, the backend must: - -### 1. Set HttpOnly Cookies - -On successful login/register/refresh: -```typescript -response.setCookie('access_token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - path: '/', - maxAge: 15 * 60 // 15 minutes -}); - -response.setCookie('refresh_token', refreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - path: '/auth/refresh', - maxAge: 7 * 24 * 60 * 60 // 7 days -}); -``` - -### 2. Support /auth/refresh Endpoint - -```typescript -POST /auth/refresh -- Read refresh_token from cookies -- Validate refresh token -- Generate new access token -- Set new access_token cookie -- Return success response -``` - -### 3. Validate Access Tokens - -```typescript -// On protected endpoints -- Read access_token from cookies -- Validate token -- Return 401 if invalid/expired -- Process request if valid -``` - -### 4. Clear Cookies on Logout - -```typescript -POST /auth/logout -- Clear access_token cookie -- Clear refresh_token cookie -- Return success response -``` - -### 5. CORS Configuration - -```typescript -app.use(cors({ - origin: process.env.FRONTEND_URL, - credentials: true, // Critical for cookies - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); -``` - -## Testing Checklist - -### Manual Testing - -- [ ] **Login Flow** - - [ ] Valid credentials → success - - [ ] Invalid credentials → error message - - [ ] Redirect after login works - - [ ] HttpOnly cookies set - -- [ ] **Register Flow** - - [ ] Valid data → account created - - [ ] Invalid data → validation errors - - [ ] Duplicate email → error - - [ ] HttpOnly cookies set - -- [ ] **Logout Flow** - - [ ] Logout clears session - - [ ] Redirect to login page - - [ ] Cookies cleared - - [ ] Protected routes inaccessible - -- [ ] **Token Refresh** - - [ ] 401 triggers refresh - - [ ] Original request retried - - [ ] New token used - - [ ] Multiple 401s use single refresh - -- [ ] **Race Conditions** - - [ ] Multiple concurrent requests - - [ ] Single refresh for all 401s - - [ ] No duplicate refreshes - -- [ ] **Session Persistence** - - [ ] Refresh page → still logged in - - [ ] New tab → still logged in - - [ ] Logout → all tabs logged out - -- [ ] **Error Handling** - - [ ] Network errors displayed - - [ ] API errors displayed - - [ ] Loading states shown - -### Security Testing - -- [ ] **XSS Protection** - - [ ] JavaScript cannot access tokens - - [ ] `document.cookie` doesn't show tokens - - [ ] Inject script → tokens safe - -- [ ] **CSRF Protection** - - [ ] Cross-origin requests blocked - - [ ] SameSite=Strict prevents CSRF - - [ ] Backend validates origin - -- [ ] **Token Security** - - [ ] Tokens expire properly - - [ ] Refresh tokens long-lived - - [ ] Access tokens short-lived - - [ ] Logout invalidates tokens - -## Migration Strategy - -### Phase 1: Authentication (✅ Complete) -- Login -- Register -- Logout -- Token refresh - -### Phase 2: User Profile (Next) -- View profile -- Edit profile -- Change password - -### Phase 3: Tasks & Contests -- Browse tasks -- Submit solutions -- View submissions -- Contest participation - -### Phase 4: Admin Features -- User management -- Task management -- Contest management - -### Phase 5: Real-time Features -- Live updates -- Notifications -- WebSocket integration - -## Backward Compatibility - -**All existing remote functions are preserved:** -- `login.remote.ts` - Still works -- `register.remote.ts` - Still works -- `logout.remote.ts` - Still works - -**Migration is additive:** -- New client API exists alongside old -- Pages can use either approach -- No breaking changes - -**Progressive enhancement:** -- Start with client API for new features -- Migrate existing features incrementally -- Test thoroughly at each step - -## Known Limitations - -1. **Client-only**: ClientApiService only works in browser -2. **CORS required**: Backend must enable CORS with credentials -3. **Cookie-dependent**: Requires browser cookie support -4. **No SSR data**: Client API doesn't pre-populate server-rendered pages - -## Future Enhancements - -### Potential Improvements - -1. **Request Interceptors** - - Global error handling - - Request logging - - Analytics integration - -2. **Response Caching** - - Cache GET requests - - Invalidate on mutations - - Reduce API calls - -3. **Retry Logic** - - Automatic retry on network errors - - Exponential backoff - - Configurable retry count - -4. **Request Cancellation** - - Cancel in-flight requests - - Prevent race conditions - - Better UX on navigation - -5. **Optimistic Updates** - - Update UI immediately - - Roll back on error - - Better perceived performance - -## Success Metrics - -### Implementation -- ✅ All acceptance criteria met -- ✅ Zero security vulnerabilities -- ✅ 100% type safety -- ✅ Comprehensive documentation -- ✅ Code review passed - -### Performance -- 🔄 Token refresh < 100ms -- 🔄 Login/register < 500ms -- 🔄 API requests < 200ms - -### Security -- ✅ HttpOnly cookies -- ✅ SameSite=Strict -- ✅ No XSS vulnerabilities -- ✅ CSRF protection - -## Conclusion - -The client API migration for authentication is **complete and ready for testing**. The implementation provides: - -1. **Secure**: HttpOnly cookies, SameSite, automatic refresh -2. **Robust**: Race condition handling, error management -3. **Documented**: 25KB of documentation and examples -4. **Type-safe**: Full TypeScript support -5. **Tested**: Code review and security scan passed -6. **Backward compatible**: Existing code untouched - -The pattern established here can be replicated for other features, enabling progressive migration from server-side remote functions to client-side API calls while maintaining security and reliability. - -## Next Steps - -1. **Environment Setup** - - Add `PUBLIC_BACKEND_API_URL` to `.env` - - Configure backend CORS - -2. **Backend Integration** - - Verify HttpOnly cookie implementation - - Test `/auth/refresh` endpoint - - Validate CORS configuration - -3. **Testing** - - Complete manual testing checklist - - Verify security measures - - Test race conditions - -4. **Deployment** - - Update production environment variables - - Enable HTTPS (Secure flag) - - Monitor for errors - -5. **Documentation** - - Share with team - - Update API documentation - - Create video walkthrough - -## Support - -For questions or issues: -- See [CLIENT_API_MIGRATION.md](./CLIENT_API_MIGRATION.md) -- See [USAGE_EXAMPLES.md](./USAGE_EXAMPLES.md) -- Check troubleshooting section -- Contact development team diff --git a/README.md b/README.md index 7398d69..c59c53e 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,28 @@ Frontend application for the programming contest platform built with SvelteKit and Svelte 5. -## Documentation - -- [Remote Functions Explanation](./REMOTE_FUNCTIONS_EXPLANATION.md) - Legacy server-side remote functions -- [Client API Migration Guide](./CLIENT_API_MIGRATION.md) - **NEW**: Direct client-to-backend API integration -- [Client API Usage Examples](./USAGE_EXAMPLES.md) - **NEW**: Practical examples for using the client API - ## Architecture -This application supports two architectural patterns: +This application uses **SvelteKit with remote functions** for server-side operations and data fetching. Additionally, it provides a **client-side API** for scenarios requiring direct browser-to-backend communication. -1. **Remote Functions (Legacy)**: Server-side form handling with SvelteKit's remote functions -2. **Client API (New)**: Direct browser-to-backend communication with HttpOnly cookies +### Client API -### Client API (Recommended for New Features) +For use cases requiring direct client-to-backend communication (e.g., real-time features, SPA-like interactions): -The new client API provides: -- Direct browser-to-backend communication -- HttpOnly cookie security for tokens -- Automatic token refresh with race condition protection -- SPA-like user experience -- Full TypeScript support +- **Global Instance**: Use `getClientApiInstance()` for a singleton API client +- **Authentication**: `ClientAuthService` for login, register, logout +- **Security**: HttpOnly cookies, automatic token refresh, CSRF protection via SameSite=Strict -See [CLIENT_API_MIGRATION.md](./CLIENT_API_MIGRATION.md) for details. +Example usage: +```typescript +import { getClientApiInstance, ClientAuthService } from '$lib/services'; + +const apiClient = getClientApiInstance(); +if (apiClient) { + const authService = new ClientAuthService(apiClient); + const result = await authService.login({ email, password }); +} +``` ## Quick Start @@ -52,7 +51,7 @@ pnpm build # Server-side (private) BACKEND_API_URL=http://localhost:8000 -# Client-side (public) +# Client-side (public) - for direct client API usage PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 ``` @@ -109,13 +108,14 @@ src/ ├── lib/ │ ├── auth/ # Client-side auth utilities │ ├── components/ # Reusable components -│ │ └── auth/ # Auth-specific components (NEW) +│ │ └── auth/ # Auth-specific components │ ├── dto/ # Data transfer objects │ ├── services/ # API services │ │ ├── ApiService.ts # Server-side API client -│ │ ├── ClientApiService.ts # Browser-side API client (NEW) +│ │ ├── ClientApiService.ts # Browser-side API client +│ │ ├── client-api-instance.ts # Global singleton instance │ │ ├── AuthService.ts # Server-side auth -│ │ └── ClientAuthService.ts # Browser-side auth (NEW) +│ │ └── ClientAuthService.ts # Browser-side auth │ └── token.ts # Token management └── routes/ # SvelteKit routes ``` diff --git a/USAGE_EXAMPLES.md b/USAGE_EXAMPLES.md deleted file mode 100644 index 33351c3..0000000 --- a/USAGE_EXAMPLES.md +++ /dev/null @@ -1,585 +0,0 @@ -# Client API Usage Examples - -This document provides practical examples of using the new client API for authentication and other features. - -## Example 1: Using ClientLoginForm Component - -The simplest way to add client-side login to a page: - -```svelte - - - -
- - -
-
- -
- - - - {m.login_title()} - {m.login_subtitle()} - - - - - - - -
-
-``` - -## Example 2: Custom Login Form with Client API - -For more control over the form layout and behavior: - -```svelte - - -
-
- - {#if errors.email} -

{errors.email}

- {/if} -
- -
- - {#if errors.password} -

{errors.password}

- {/if} -
- - -
-``` - -## Example 3: Using ClientLogoutButton - -Replace remote function logout with client logout: - -```svelte - - - - - Logout - - - - - - {m.sidebar_logout()} - -``` - -## Example 4: Programmatic Logout - -For logout triggered from JavaScript logic: - -```svelte - -``` - -## Example 5: Registration Form - -```svelte - - -
- - - - - - - - -
-``` - -## Example 6: Proactive Token Refresh - -Refresh token before it expires to maintain smooth UX: - -```svelte - -``` - -## Example 7: Handling Auth State in Layout - -Check auth status and display user info: - -```svelte - - -{#if user} - -{:else} - -{/if} -``` - -## Example 8: Protected API Calls - -Make authenticated requests to other endpoints: - -```svelte - - -{#if isLoading} -

Loading...

-{:else if userProfile} -
-

{userProfile.name} {userProfile.surname}

-

{userProfile.email}

-
-{:else} -

Failed to load profile

-{/if} -``` - -## Example 9: Form with Loading States - -Proper loading and error states: - -```svelte - - -
- {#if error} -
- {error} -
- {/if} - - - - - - -
-``` - -## Example 10: Environment-Specific API URL - -Override API URL for different environments: - -```svelte - -``` - -## Best Practices - -### 1. Always Check Browser Context - -```typescript -const apiClient = browser ? createClientApiClient() : null; -``` - -### 2. Handle Service Unavailability - -```typescript -if (!authService) { - toast.error('Service not available'); - return; -} -``` - -### 3. Use Loading States - -```typescript -let isLoading = $state(false); - -async function doSomething() { - isLoading = true; - try { - // ... - } finally { - isLoading = false; - } -} -``` - -### 4. Proper Error Handling - -```typescript -try { - const result = await authService.login(data); - if (result.success) { - // Success - } else { - // Handle API error - toast.error(result.error); - } -} catch (error) { - // Handle unexpected error - console.error(error); - toast.error('Unexpected error'); -} -``` - -### 5. Use TypeScript Types - -```typescript -import type { UserLoginDto } from '$lib/dto/user'; -import type { ApiResponse } from '$lib/dto/response'; -``` - -## Migration Checklist - -When migrating a feature from remote functions to client API: - -- [ ] Create/use appropriate client service -- [ ] Handle browser context check -- [ ] Implement loading states -- [ ] Add error handling -- [ ] Test success path -- [ ] Test error paths -- [ ] Test loading states -- [ ] Update navigation/redirects -- [ ] Remove old remote function (if fully migrated) -- [ ] Update documentation - -## Common Pitfalls - -### ❌ Don't: Create client on server - -```typescript -// This will error! -const apiClient = createClientApiClient(); -``` - -### ✅ Do: Check browser context - -```typescript -const apiClient = browser ? createClientApiClient() : null; -``` - -### ❌ Don't: Forget loading states - -```typescript -// Bad UX - no feedback -await authService.login(data); -``` - -### ✅ Do: Show loading states - -```typescript -isSubmitting = true; -try { - await authService.login(data); -} finally { - isSubmitting = false; -} -``` - -### ❌ Don't: Ignore errors - -```typescript -// Silent failure -const result = await authService.login(data); -``` - -### ✅ Do: Handle errors properly - -```typescript -const result = await authService.login(data); -if (!result.success) { - toast.error(result.error); -} -``` - -## Next Steps - -After implementing authentication with client API: - -1. Test thoroughly with backend -2. Monitor for errors in production -3. Migrate other features incrementally -4. Update team documentation -5. Share learnings with team diff --git a/src/lib/auth/client-logout.ts b/src/lib/auth/client-logout.ts index ad2ed63..fcf1ee1 100644 --- a/src/lib/auth/client-logout.ts +++ b/src/lib/auth/client-logout.ts @@ -1,7 +1,7 @@ import { goto } from '$app/navigation'; import { browser } from '$app/environment'; import { AppRoutes } from '$lib/routes'; -import { createClientApiClient, ClientAuthService } from '$lib/services'; +import { getClientApiInstance, ClientAuthService } from '$lib/services'; /** * Client-side logout function @@ -14,7 +14,12 @@ export async function clientLogout(): Promise { } try { - const apiClient = createClientApiClient(); + const apiClient = getClientApiInstance(); + if (!apiClient) { + console.error('API client not available'); + return; + } + const authService = new ClientAuthService(apiClient); const result = await authService.logout(); diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte index 00e37ca..3b587ac 100644 --- a/src/lib/components/auth/ClientLoginForm.svelte +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -1,13 +1,14 @@ + + From b7b6d579ec84ff6033cbd503d71cf8a829533d5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:24:54 +0000 Subject: [PATCH 11/18] Migrate login and register pages to use new Superforms-based client API components Co-authored-by: TheRealSeber <111927572+TheRealSeber@users.noreply.github.com> --- messages/en.json | 3 +- messages/pl.json | 3 +- .../components/auth/ClientRegisterForm.svelte | 254 ++++++++++++++++++ src/lib/components/auth/index.ts | 1 + src/routes/(landing)/login/+page.svelte | 76 +----- src/routes/(landing)/register/+page.svelte | 132 +-------- 6 files changed, 267 insertions(+), 202 deletions(-) create mode 100644 src/lib/components/auth/ClientRegisterForm.svelte diff --git a/messages/en.json b/messages/en.json index c41e47b..db7b9da 100644 --- a/messages/en.json +++ b/messages/en.json @@ -575,5 +575,6 @@ "contest_collaborators_remove_success": "Collaborator removed successfully!", "contest_collaborators_remove_error": "Failed to remove collaborator", "admin_contests_card_view_collaborators": "View Collaborators", - "login_success": "Login successful!" + "login_success": "Login successful!", + "register_success": "Registration successful! Welcome!" } diff --git a/messages/pl.json b/messages/pl.json index a1bf626..25bef1d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -575,5 +575,6 @@ "contest_collaborators_remove_success": "Współpracownik został usunięty pomyślnie!", "contest_collaborators_remove_error": "Nie udało się usunąć współpracownika", "admin_contests_card_view_collaborators": "Zobacz Współpracowników", - "login_success": "Logowanie pomyślne!" + "login_success": "Logowanie pomyślne!", + "register_success": "Rejestracja pomyślna! Witaj!" } diff --git a/src/lib/components/auth/ClientRegisterForm.svelte b/src/lib/components/auth/ClientRegisterForm.svelte new file mode 100644 index 0000000..e70d6a6 --- /dev/null +++ b/src/lib/components/auth/ClientRegisterForm.svelte @@ -0,0 +1,254 @@ + + +
+
+ + + {#if $errors.email} +

{$errors.email}

+ {/if} +
+ +
+ + + {#if $errors.name} +

{$errors.name}

+ {/if} +
+ +
+ + + {#if $errors.surname} +

{$errors.surname}

+ {/if} +
+ +
+ + + {#if $errors.username} +

{$errors.username}

+ {/if} +
+ +
+ + + {#if $errors.password} +

{$errors.password}

+ {/if} +
+ +
+ + + {#if $errors.confirmPassword} +

{$errors.confirmPassword}

+ {/if} +
+ + +
diff --git a/src/lib/components/auth/index.ts b/src/lib/components/auth/index.ts index 7625658..fcb24c5 100644 --- a/src/lib/components/auth/index.ts +++ b/src/lib/components/auth/index.ts @@ -1,2 +1,3 @@ export { default as ClientLoginForm } from './ClientLoginForm.svelte'; +export { default as ClientRegisterForm } from './ClientRegisterForm.svelte'; export { default as ClientLogoutButton } from './ClientLogoutButton.svelte'; diff --git a/src/routes/(landing)/login/+page.svelte b/src/routes/(landing)/login/+page.svelte index 069d391..6f4143b 100644 --- a/src/routes/(landing)/login/+page.svelte +++ b/src/routes/(landing)/login/+page.svelte @@ -1,16 +1,15 @@
-
{ - try { - await submit(); - } catch (error: HttpError | unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.error_default_message()); - } - } - })} - class="space-y-6" - > -
- - - {#each login.fields.email.issues() as issue} -

{issue.message}

- {/each} -
- -
-
- - - {m.login_forgot_password()} - -
- - {#each login.fields._password.issues() as issue} -

{issue.message}

- {/each} -
- - -
+
diff --git a/src/routes/(landing)/register/+page.svelte b/src/routes/(landing)/register/+page.svelte index d83c3f6..95d092f 100644 --- a/src/routes/(landing)/register/+page.svelte +++ b/src/routes/(landing)/register/+page.svelte @@ -1,16 +1,11 @@
-
{ - try { - await submit(); - } catch (error: HttpError | unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.error_default_message()); - } - } - })} - class="space-y-4" - > -
- - - {#each register.fields.email.issues() as issue} -

{issue.message}

- {/each} -
- -
- - - {#each register.fields.name.issues() as issue} -

{issue.message}

- {/each} -
- -
- - - {#each register.fields.surname.issues() as issue} -

{issue.message}

- {/each} -
- -
- - - {#each register.fields.username.issues() as issue} -

{issue.message}

- {/each} -
- -
- - - {#each register.fields._password.issues() as issue} -

{issue.message}

- {/each} -
- -
- - - {#each register.fields._confirmPassword.issues() as issue} -

{issue.message}

- {/each} -
- - -
+
From 8380e8beb1303ed70c41d3b940f22f8e1dfedac2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 10:16:03 +0000 Subject: [PATCH 12/18] Refactor auth forms to use shared schemas and remove unnecessary onSubmit in SPA mode Co-authored-by: TheRealSeber <111927572+TheRealSeber@users.noreply.github.com> --- .../components/auth/ClientLoginForm.svelte | 29 +------ .../components/auth/ClientRegisterForm.svelte | 68 +--------------- src/lib/schemas/auth.ts | 77 +++++++++++++++++++ src/lib/schemas/index.ts | 1 + 4 files changed, 86 insertions(+), 89 deletions(-) create mode 100644 src/lib/schemas/auth.ts create mode 100644 src/lib/schemas/index.ts diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte index 4b93638..dbbcfe1 100644 --- a/src/lib/components/auth/ClientLoginForm.svelte +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -8,7 +8,7 @@ import { AppRoutes } from '$lib/routes'; import { getClientAuthInstance } from '$lib/services'; import { browser } from '$app/environment'; - import * as v from 'valibot'; + import { LoginSchema } from '$lib/schemas'; import { superForm } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; @@ -21,20 +21,7 @@ // Get singleton auth service instance const authService = browser ? getClientAuthInstance() : null; - // Valibot schema matching the remote function pattern - const LoginSchema = v.object({ - email: v.pipe( - v.string(m.validation_email_required()), - v.nonEmpty(m.validation_email_required()), - v.email(m.validation_email_invalid()) - ), - password: v.pipe( - v.string(m.validation_password_required()), - v.nonEmpty(m.validation_password_required()) - ) - }); - - // Initialize superform for client-side usage + // Initialize superform for SPA mode with client-side validation const { form, errors, enhance, submitting } = superForm( { email: '', @@ -45,16 +32,8 @@ SPA: true, dataType: 'json', resetForm: false, - onSubmit: async ({ formData, cancel }) => { - // Cancel default form submission since we're doing client-side API call - cancel(); - - if (!authService) { - toast.error('Authentication service not available'); - return; - } - }, - onUpdate: async ({ form }) => { + // In SPA mode, onUpdate is called after successful client-side validation + async onUpdate({ form }) { if (!authService || !form.valid) { return; } diff --git a/src/lib/components/auth/ClientRegisterForm.svelte b/src/lib/components/auth/ClientRegisterForm.svelte index e70d6a6..47bca5f 100644 --- a/src/lib/components/auth/ClientRegisterForm.svelte +++ b/src/lib/components/auth/ClientRegisterForm.svelte @@ -8,7 +8,7 @@ import { AppRoutes } from '$lib/routes'; import { getClientAuthInstance } from '$lib/services'; import { browser } from '$app/environment'; - import * as v from 'valibot'; + import { RegisterSchema } from '$lib/schemas'; import { superForm } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; @@ -21,60 +21,7 @@ // Get singleton auth service instance const authService = browser ? getClientAuthInstance() : null; - // Valibot schema matching the remote function pattern - const RegisterSchema = v.pipe( - v.object({ - email: v.pipe( - v.string(m.validation_email_required()), - v.nonEmpty(m.validation_email_required()), - v.email(m.validation_email_invalid()) - ), - name: v.pipe( - v.string(m.validation_name_required()), - v.nonEmpty(m.validation_name_required()), - v.minLength(3, m.validation_name_min()), - v.maxLength(50, m.validation_name_max()) - ), - surname: v.pipe( - v.string(m.validation_surname_required()), - v.nonEmpty(m.validation_surname_required()), - v.minLength(3, m.validation_surname_min()), - v.maxLength(50, m.validation_surname_max()) - ), - username: v.pipe( - v.string(m.validation_username_required()), - v.nonEmpty(m.validation_username_required()), - v.minLength(3, m.validation_username_min()), - v.maxLength(30, m.validation_username_max()), - v.regex(/^[a-zA-Z]/, m.validation_username_start()), - v.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, m.validation_username_pattern()) - ), - password: v.pipe( - v.string(m.validation_password_required()), - v.nonEmpty(m.validation_password_required()), - v.minLength(8, m.validation_password_min()), - v.maxLength(50, m.validation_password_max()), - v.regex(/[A-Z]/, m.validation_password_uppercase()), - v.regex(/[a-z]/, m.validation_password_lowercase()), - v.regex(/[0-9]/, m.validation_password_digit()), - v.regex(/[!#?@$%^&*-]/, m.validation_password_special()) - ), - confirmPassword: v.pipe( - v.string(m.validation_confirm_password_required()), - v.nonEmpty(m.validation_confirm_password_required()) - ) - }), - v.forward( - v.partialCheck( - [['password'], ['confirmPassword']], - (input) => input.password === input.confirmPassword, - m.validation_passwords_match() - ), - ['confirmPassword'] - ) - ); - - // Initialize superform for client-side usage + // Initialize superform for SPA mode with client-side validation const { form, errors, enhance, submitting } = superForm( { email: '', @@ -89,15 +36,8 @@ SPA: true, dataType: 'json', resetForm: false, - onSubmit: async ({ cancel }) => { - cancel(); - - if (!authService) { - toast.error('Authentication service not available'); - return; - } - }, - onUpdate: async ({ form }) => { + // In SPA mode, onUpdate is called after successful client-side validation + async onUpdate({ form }) { if (!authService || !form.valid) { return; } diff --git a/src/lib/schemas/auth.ts b/src/lib/schemas/auth.ts new file mode 100644 index 0000000..50342a9 --- /dev/null +++ b/src/lib/schemas/auth.ts @@ -0,0 +1,77 @@ +import * as v from 'valibot'; +import * as m from '$lib/paraglide/messages'; + +/** + * Login schema for client-side authentication + * Matches the validation rules from login.remote.ts + */ +export const LoginSchema = v.object({ + email: v.pipe( + v.string(m.validation_email_required()), + v.nonEmpty(m.validation_email_required()), + v.email(m.validation_email_invalid()) + ), + password: v.pipe( + v.string(m.validation_password_required()), + v.nonEmpty(m.validation_password_required()) + ) +}); + +/** + * Register schema for client-side authentication + * Matches the validation rules from register.remote.ts + */ +export const RegisterSchema = v.pipe( + v.object({ + email: v.pipe( + v.string(m.validation_email_required()), + v.nonEmpty(m.validation_email_required()), + v.email(m.validation_email_invalid()) + ), + name: v.pipe( + v.string(m.validation_name_required()), + v.nonEmpty(m.validation_name_required()), + v.minLength(3, m.validation_name_min()), + v.maxLength(50, m.validation_name_max()) + ), + surname: v.pipe( + v.string(m.validation_surname_required()), + v.nonEmpty(m.validation_surname_required()), + v.minLength(3, m.validation_surname_min()), + v.maxLength(50, m.validation_surname_max()) + ), + username: v.pipe( + v.string(m.validation_username_required()), + v.nonEmpty(m.validation_username_required()), + v.minLength(3, m.validation_username_min()), + v.maxLength(30, m.validation_username_max()), + v.regex(/^[a-zA-Z]/, m.validation_username_start()), + v.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, m.validation_username_pattern()) + ), + password: v.pipe( + v.string(m.validation_password_required()), + v.nonEmpty(m.validation_password_required()), + v.minLength(8, m.validation_password_min()), + v.maxLength(50, m.validation_password_max()), + v.regex(/[A-Z]/, m.validation_password_uppercase()), + v.regex(/[a-z]/, m.validation_password_lowercase()), + v.regex(/[0-9]/, m.validation_password_digit()), + v.regex(/[!#?@$%^&*-]/, m.validation_password_special()) + ), + confirmPassword: v.pipe( + v.string(m.validation_confirm_password_required()), + v.nonEmpty(m.validation_confirm_password_required()) + ) + }), + v.forward( + v.partialCheck( + [['password'], ['confirmPassword']], + (input) => input.password === input.confirmPassword, + m.validation_passwords_match() + ), + ['confirmPassword'] + ) +); + +export type LoginInput = v.InferInput; +export type RegisterInput = v.InferInput; diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts new file mode 100644 index 0000000..9c954b4 --- /dev/null +++ b/src/lib/schemas/index.ts @@ -0,0 +1 @@ +export { LoginSchema, RegisterSchema, type LoginInput, type RegisterInput } from './auth'; From 2758655bbcf2854c79d85f11c5d5666957b337f5 Mon Sep 17 00:00:00 2001 From: TheRealSeber Date: Sun, 7 Dec 2025 19:23:45 +0100 Subject: [PATCH 13/18] feat: adjust a lot of code --- .github/copilot-instructions.md | 1 + README.md | 1 + src/hooks.server.ts | 36 +------- .../components/auth/ClientLoginForm.svelte | 71 ++++++++------- .../components/auth/ClientRegisterForm.svelte | 87 +++++++++---------- .../dashboard/DashboardSidebar.svelte | 34 ++++---- src/lib/components/ui/form/form-button.svelte | 4 +- .../ui/form/form-description.svelte | 22 ++--- .../ui/form/form-element-field.svelte | 36 ++++---- .../ui/form/form-field-errors.svelte | 46 +++++----- src/lib/components/ui/form/form-field.svelte | 41 ++++----- .../components/ui/form/form-fieldset.svelte | 22 ++--- src/lib/components/ui/form/form-label.svelte | 36 ++++---- src/lib/components/ui/form/form-legend.svelte | 20 ++--- src/lib/components/ui/form/index.ts | 56 ++++++------ src/lib/services/ApiService.ts | 4 +- src/lib/services/client-api-instance.ts | 52 ----------- .../services/{ => client}/ClientApiService.ts | 69 ++++++++++----- .../{ => client}/ClientAuthService.ts | 29 +++++-- src/lib/services/client/ClientUserService.ts | 47 ++++++++++ src/lib/services/index.ts | 11 ++- src/lib/stores/service-instances.svelte.ts | 83 ++++++++++++++++++ src/lib/stores/token-store.svelte.ts | 42 +++++++++ src/lib/stores/user-store.svelte.ts | 66 ++++++++++++++ src/routes/+layout.svelte | 21 +++++ src/routes/dashboard/+layout.server.ts | 5 -- src/routes/dashboard/+layout.svelte | 83 ++++++++++++++---- src/routes/dashboard/logout.remote.ts | 32 ------- .../[contestId]/collaborators/+page.svelte | 5 +- .../tasks/[taskId]/collaborators/+page.svelte | 5 +- 30 files changed, 649 insertions(+), 418 deletions(-) delete mode 100644 src/lib/services/client-api-instance.ts rename src/lib/services/{ => client}/ClientApiService.ts (75%) rename src/lib/services/{ => client}/ClientAuthService.ts (73%) create mode 100644 src/lib/services/client/ClientUserService.ts create mode 100644 src/lib/stores/service-instances.svelte.ts create mode 100644 src/lib/stores/token-store.svelte.ts create mode 100644 src/lib/stores/user-store.svelte.ts delete mode 100644 src/routes/dashboard/+layout.server.ts delete mode 100644 src/routes/dashboard/logout.remote.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d94dd3b..0690c65 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -869,6 +869,7 @@ For scenarios requiring direct browser-to-backend communication: - **Use Cases**: Real-time features, SPA-like interactions, progressive enhancement Example: + ```typescript import { getClientApiInstance, ClientAuthService } from '$lib/services'; diff --git a/README.md b/README.md index c59c53e..2372668 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ For use cases requiring direct client-to-backend communication (e.g., real-time - **Security**: HttpOnly cookies, automatic token refresh, CSRF protection via SameSite=Strict Example usage: + ```typescript import { getClientApiInstance, ClientAuthService } from '$lib/services'; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 159a231..e0af63a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,11 +1,5 @@ import type { Handle } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; -import { redirect } from '@sveltejs/kit'; -import { sequence } from '@sveltejs/kit/hooks'; -import { localizeUrl } from '$lib/paraglide/runtime'; -import { AppRoutes, isProtectedRoute } from '$lib/routes'; -import { ACCESS_TOKEN_KEY } from '$lib/token'; -import { decodeAccessToken } from '$lib/jwt'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { @@ -16,32 +10,4 @@ const handleParaglide: Handle = ({ event, resolve }) => }); }); -const handleAuth: Handle = async ({ event, resolve }) => { - event.locals.user = null; - - const accessToken = event.cookies.get(ACCESS_TOKEN_KEY); - if (accessToken) { - const user = decodeAccessToken(accessToken); - if (user) { - event.locals.user = user; - } - } - - if (isProtectedRoute(event.url.pathname)) { - if (!event.locals.user) { - // Store the original URL to redirect back after login - const redirectTo = event.url.pathname + event.url.search; - - // Localize the login URL with the redirectTo parameter - const loginUrl = new URL(AppRoutes.Login, event.url.origin); - loginUrl.searchParams.set('redirectTo', redirectTo); - const localizedLoginUrl = localizeUrl(loginUrl); - - redirect(307, localizedLoginUrl.pathname + localizedLoginUrl.search); - } - } - - return resolve(event); -}; - -export const handle: Handle = sequence(handleParaglide, handleAuth); +export const handle: Handle = handleParaglide; diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte index dbbcfe1..234515f 100644 --- a/src/lib/components/auth/ClientLoginForm.svelte +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -6,10 +6,10 @@ import { Label } from '$lib/components/ui/label'; import { toast } from 'svelte-sonner'; import { AppRoutes } from '$lib/routes'; - import { getClientAuthInstance } from '$lib/services'; + import { getClientAuthInstance, getClientUserInstance } from '$lib/services'; import { browser } from '$app/environment'; import { LoginSchema } from '$lib/schemas'; - import { superForm } from 'sveltekit-superforms'; + import { defaults, superForm } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; interface Props { @@ -22,49 +22,46 @@ const authService = browser ? getClientAuthInstance() : null; // Initialize superform for SPA mode with client-side validation - const { form, errors, enhance, submitting } = superForm( - { - email: '', - password: '' - }, - { - validators: valibot(LoginSchema), - SPA: true, - dataType: 'json', - resetForm: false, - // In SPA mode, onUpdate is called after successful client-side validation - async onUpdate({ form }) { - if (!authService || !form.valid) { - return; - } + const { form, errors, enhance, submitting } = superForm(defaults(valibot(LoginSchema)), { + validators: valibot(LoginSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!authService || !form.valid) { + return; + } - try { - const loginResult = await authService.login({ - email: form.data.email.trim(), - password: form.data.password - }); + try { + const loginResult = await authService.login({ + email: form.data.email.trim(), + password: form.data.password + }); - if (loginResult.success) { - toast.success(m.login_success()); + if (loginResult.success) { + // Fetch user data to populate userStore + const userService = browser ? getClientUserInstance() : null; + if (userService) { + await userService.getCurrentUser(); + } - // Sanitize redirectTo to prevent open redirect vulnerability - // Only allow relative paths starting with / - let safeRedirect: string = AppRoutes.Dashboard; - if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) { - safeRedirect = redirectTo; - } + toast.success(m.login_success()); - await goto(safeRedirect); - } else { - toast.error(loginResult.error || m.error_default_message()); + let safeRedirect: string = AppRoutes.Dashboard; + if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) { + safeRedirect = redirectTo; } - } catch (error) { - console.error('Login error:', error); - toast.error(m.error_default_message()); + + await goto(safeRedirect); + } else { + toast.error(loginResult.error || m.error_default_message()); } + } catch (error) { + console.error('Login error:', error); + toast.error(m.error_default_message()); } } - ); + });
diff --git a/src/lib/components/auth/ClientRegisterForm.svelte b/src/lib/components/auth/ClientRegisterForm.svelte index 47bca5f..539f4f0 100644 --- a/src/lib/components/auth/ClientRegisterForm.svelte +++ b/src/lib/components/auth/ClientRegisterForm.svelte @@ -6,10 +6,10 @@ import { Label } from '$lib/components/ui/label'; import { toast } from 'svelte-sonner'; import { AppRoutes } from '$lib/routes'; - import { getClientAuthInstance } from '$lib/services'; + import { getClientAuthInstance, getClientUserInstance } from '$lib/services'; import { browser } from '$app/environment'; import { RegisterSchema } from '$lib/schemas'; - import { superForm } from 'sveltekit-superforms'; + import { superForm, defaults } from 'sveltekit-superforms'; import { valibot } from 'sveltekit-superforms/adapters'; interface Props { @@ -22,56 +22,49 @@ const authService = browser ? getClientAuthInstance() : null; // Initialize superform for SPA mode with client-side validation - const { form, errors, enhance, submitting } = superForm( - { - email: '', - name: '', - surname: '', - username: '', - password: '', - confirmPassword: '' - }, - { - validators: valibot(RegisterSchema), - SPA: true, - dataType: 'json', - resetForm: false, - // In SPA mode, onUpdate is called after successful client-side validation - async onUpdate({ form }) { - if (!authService || !form.valid) { - return; - } + const { form, errors, enhance, submitting } = superForm(defaults(valibot(RegisterSchema)), { + validators: valibot(RegisterSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!authService || !form.valid) { + return; + } - try { - const registerResult = await authService.register({ - email: form.data.email.trim(), - name: form.data.name.trim(), - surname: form.data.surname.trim(), - username: form.data.username.trim(), - password: form.data.password, - confirmPassword: form.data.confirmPassword - }); - - if (registerResult.success) { - toast.success(m.register_success()); - - // Sanitize redirectTo to prevent open redirect vulnerability - let safeRedirect: string = AppRoutes.Dashboard; - if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) { - safeRedirect = redirectTo; - } - - await goto(safeRedirect); - } else { - toast.error(registerResult.error || m.error_default_message()); + try { + const registerResult = await authService.register({ + email: form.data.email.trim(), + name: form.data.name.trim(), + surname: form.data.surname.trim(), + username: form.data.username.trim(), + password: form.data.password, + confirmPassword: form.data.confirmPassword + }); + + if (registerResult.success) { + const userService = browser ? getClientUserInstance() : null; + if (userService) { + await userService.getCurrentUser(); } - } catch (error) { - console.error('Register error:', error); - toast.error(m.error_default_message()); + + toast.success(m.register_success()); + + let safeRedirect: string = AppRoutes.Dashboard; + if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) { + safeRedirect = redirectTo; + } + + await goto(safeRedirect); + } else { + toast.error(registerResult.error || m.error_default_message()); } + } catch (error) { + console.error('Register error:', error); + toast.error(m.error_default_message()); } } - ); + }); diff --git a/src/lib/components/dashboard/DashboardSidebar.svelte b/src/lib/components/dashboard/DashboardSidebar.svelte index 92f9a14..88ac8eb 100644 --- a/src/lib/components/dashboard/DashboardSidebar.svelte +++ b/src/lib/components/dashboard/DashboardSidebar.svelte @@ -29,13 +29,12 @@ import Activity from '@lucide/svelte/icons/activity'; import LogOut from '@lucide/svelte/icons/log-out'; import SidebarTrigger from '../ui/sidebar/sidebar-trigger.svelte'; - import { logout } from '$routes/dashboard/logout.remote'; + import { userStore } from '$lib/stores/user-store.svelte'; + import { getClientAuthInstance } from '$lib/services'; + import { goto } from '$app/navigation'; + import { browser } from '$app/environment'; - interface Props { - user: { userId: number; role: UserRole }; - } - - let { user }: Props = $props(); + const user = $derived(userStore.getUserUnsafe()); const userMenuItems = [ { title: () => m.sidebar_your_submissions(), @@ -242,16 +241,21 @@ {#snippet child({ props })} - { - await submit(); - })} + - + + {m.sidebar_logout()} + {/snippet}
diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte index 48d3936..0133709 100644 --- a/src/lib/components/ui/form/form-button.svelte +++ b/src/lib/components/ui/form/form-button.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte b/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte index d864671..b206a25 100644 --- a/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte +++ b/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte @@ -9,18 +9,22 @@ import * as m from '$lib/paraglide/messages'; import { SvelteSet } from 'svelte/reactivity'; import type { Group } from '$lib/dto/group'; - import { addGroupsToContest } from '$routes/dashboard/teacher/contests/[contestId]/groups/groups.remote'; + import { getContestsManagementInstance } from '$lib/services'; interface Props { contestId: number; assignableGroups: Group[]; + onSuccess?: () => void; } - let { contestId, assignableGroups }: Props = $props(); + let { contestId, assignableGroups, onSuccess }: Props = $props(); let dialogOpen = $state(false); let searchQuery = $state(''); let selectedGroupIds = $state(new SvelteSet()); + let submitting = $state(false); + + const contestsService = getContestsManagementInstance(); let filteredGroups = $derived.by(() => { if (!searchQuery.trim()) return assignableGroups; @@ -39,19 +43,24 @@ } async function handleSubmit() { - if (selectedGroupIds.size === 0) { + if (!contestsService || selectedGroupIds.size === 0) { toast.error(m.contest_groups_add_error()); return; } + submitting = true; try { - await addGroupsToContest({ contestId, groupIds: Array.from(selectedGroupIds) }); + await contestsService.addGroupsToContest(contestId, Array.from(selectedGroupIds)); toast.success(m.contest_groups_add_success()); dialogOpen = false; selectedGroupIds = new SvelteSet(); searchQuery = ''; - } catch { + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Add groups to contest error:', error); toast.error(m.contest_groups_add_error()); + } finally { + submitting = false; } } @@ -141,11 +150,11 @@
- - diff --git a/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte b/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte index 6f5dea3..1024fcd 100644 --- a/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte +++ b/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte @@ -6,18 +6,20 @@ import ChevronDown from '@lucide/svelte/icons/chevron-down'; import ChevronUp from '@lucide/svelte/icons/chevron-up'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; - import { Permission } from '$lib/dto/accessControl'; - import type { UpdateCollaboratorForm } from '$routes/dashboard/teacher/contests/[contestId]/collaborators/collaborators.remote'; + import { Permission, ResourceType } from '$lib/dto/accessControl'; + import { getAccessControlInstance } from '$lib/services'; + import { UpdateCollaboratorSchema } from '$lib/schemas'; + import { defaults, superForm } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; interface Props { contestId: number; userId: number; userName: string; currentPermission: Permission; - updateCollaborator: UpdateCollaboratorForm; canEdit?: boolean; + onSuccess?: () => void; } let { @@ -25,18 +27,49 @@ userId, userName, currentPermission, - updateCollaborator, - canEdit = false + canEdit = false, + onSuccess }: Props = $props(); + const accessControlService = getAccessControlInstance(); + let popoverOpen = $state(false); let dialogOpen = $state(false); let selectedPermission = $state(null); - let isUpdating = $state(false); // Owner permission cannot be changed, and user needs canEdit permission const isEditable = $derived(canEdit && currentPermission !== Permission.Owner); + const { form, errors, enhance, submitting } = superForm( + defaults({ resourceId: contestId, userId, permission: currentPermission }, valibot(UpdateCollaboratorSchema)), + { + id: `contest-collab-perm-${contestId}-${userId}`, + validators: valibot(UpdateCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.updateCollaborator( + ResourceType.Contests, + form.data.resourceId, + form.data.userId, + { permission: form.data.permission as Permission.Edit | Permission.Manage } + ); + toast.success(m.contest_collaborators_update_success()); + dialogOpen = false; + selectedPermission = null; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Update contest collaborator error:', error); + toast.error(m.contest_collaborators_update_error()); + } + } + } + ); + function getPermissionLabel(permission: Permission): string { switch (permission) { case Permission.Edit: @@ -72,6 +105,7 @@ // Different permission selected, show confirmation dialog selectedPermission = permission; + $form.permission = permission; popoverOpen = false; dialogOpen = true; } @@ -151,35 +185,13 @@ -
{ - isUpdating = true; - try { - await submit(); - toast.success(m.contest_collaborators_update_success()); - dialogOpen = false; - selectedPermission = null; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.contest_collaborators_update_error()); - } - } finally { - isUpdating = false; - } - })} - > - - - - + - -
diff --git a/src/lib/components/dashboard/admin/contests/CreateContestButton.svelte b/src/lib/components/dashboard/admin/contests/CreateContestButton.svelte index b6d009f..cdead59 100644 --- a/src/lib/components/dashboard/admin/contests/CreateContestButton.svelte +++ b/src/lib/components/dashboard/admin/contests/CreateContestButton.svelte @@ -9,21 +9,69 @@ import Plus from '@lucide/svelte/icons/plus'; import CalendarIcon from '@lucide/svelte/icons/calendar'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; import { DateFormatter, type DateValue, getLocalTimeZone, today } from '@internationalized/date'; import { cn, toLocalRFC3339 } from '$lib/utils'; import { SvelteDate } from 'svelte/reactivity'; - import type { CreateContestForm } from '$routes/dashboard/teacher/contests/contests.remote'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { CreateContestSchema } from '$lib/schemas'; + import { getContestsManagementInstance } from '$lib/services'; interface Props { - createContest: CreateContestForm; + onSuccess?: () => void; } - let { createContest }: Props = $props(); + let { onSuccess }: Props = $props(); + + const contestsService = getContestsManagementInstance(); let dialogOpen = $state(false); + // Initialize superform for SPA mode with client-side validation + const { form, errors, enhance, submitting } = superForm( + defaults( + { + name: '', + description: '', + startAt: '', + endAt: '', + isRegistrationOpen: false, + isSubmissionOpen: false, + isVisible: false + }, + valibot(CreateContestSchema) + ), + { + id: 'create-contest', + validators: valibot(CreateContestSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!contestsService || !form.valid) return; + + try { + await contestsService.createContest({ + name: form.data.name, + description: form.data.description, + startAt: form.data.startAt, + endAt: form.data.endAt || undefined, + isRegistrationOpen: form.data.isRegistrationOpen, + isSubmissionOpen: form.data.isSubmissionOpen, + isVisible: form.data.isVisible + }); + toast.success(m.admin_contests_create_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Create contest error:', error); + toast.error(m.admin_contests_create_error()); + } + } + } + ); + // Date formatters const df = new DateFormatter('en-US', { dateStyle: 'long' @@ -65,8 +113,11 @@ return toLocalRFC3339(dateObj); } - let startAtValue = $derived(getDateTimeString(startDate, startTime)); - let endAtValue = $derived(hasEndTime ? getDateTimeString(endDate, endTime) : ''); + // Update form values when date/time changes + $effect(() => { + $form.startAt = getDateTimeString(startDate, startTime); + $form.endAt = hasEndTime ? getDateTimeString(endDate, endTime) : ''; + }); @@ -103,57 +154,40 @@ -
{ - try { - await submit(); - toast.success(m.admin_contests_create_success()); - dialogOpen = false; - } catch (error) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.admin_contests_create_error()); - } - } - })} - class="space-y-6" - > - - - +
- {#each createContest.fields.name.issues() as issue (issue.message)} -

{issue.message}

- {/each} + {#if $errors.name} +

{$errors.name}

+ {/if}
- {#each createContest.fields.description.issues() as issue (issue.message)} -

{issue.message}

- {/each} + {#if $errors.description} +

{$errors.description}

+ {/if}
@@ -201,9 +235,9 @@ />
- {#each createContest.fields.startAt.issues() as issue (issue.message)} -

{issue.message}

- {/each} + {#if $errors.startAt} +

{$errors.startAt}

+ {/if}
@@ -256,9 +290,9 @@ /> - {#each createContest.fields.endAt.issues() as issue (issue.message)} -

{issue.message}

- {/each} + {#if $errors.endAt} +

{$errors.endAt}

+ {/if} {/if} @@ -269,7 +303,9 @@
- {#each updateContest.fields.endAt.issues() ?? [] as issue (issue.message)} -

{issue.message}

- {/each} + {#if $errors.endAt} +

{$errors.endAt}

+ {/if} {/if} @@ -252,8 +284,10 @@