diff --git a/.env.example b/.env.example index 73d10f9..c0ca21e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ BACKEND_API_URL=http://localhost:8000 +PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 FILE_STORAGE_URL=http://file-storage:8888 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 420f9c0..0690c65 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -859,6 +859,29 @@ pnpm run check # Type checking - Use `TokenManager` for cookie operations - All API requests include authentication via `createApiClient(cookies)` +### Client-Side API (Optional Pattern) + +For scenarios requiring direct browser-to-backend communication: + +- **Global Instance**: Use `getClientApiInstance()` for singleton API client +- **Client Services**: `ClientApiService` and `ClientAuthService` +- **Security**: HttpOnly cookies, automatic token refresh, race condition protection +- **Use Cases**: Real-time features, SPA-like interactions, progressive enhancement + +Example: + +```typescript +import { getClientApiInstance, ClientAuthService } from '$lib/services'; + +const apiClient = getClientApiInstance(); +if (apiClient) { + const authService = new ClientAuthService(apiClient); + await authService.login({ email, password }); +} +``` + +**Note**: Prefer remote functions for most server-side operations. Use client API only when direct browser-to-backend communication is specifically needed. + ## Important Notes 1. **Always use remote functions** for server-side operations - don't use `+page.server.ts` unless absolutely necessary diff --git a/CLAUDE.md b/CLAUDE.md index c7799ec..401d686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,43 +55,37 @@ docker run -p 3000:3000 \ ## Critical Architecture Patterns -### 1. Remote Functions (Primary Server Pattern) +### 1. Client-Side Service Pattern (Direct API Integration) -This project uses **SvelteKit remote functions** (`experimental.remoteFunctions: true`) instead of traditional `+page.server.ts` load functions. +This project uses **direct client-to-backend API communication** through service classes with browser-side token management. -**Three types of remote functions:** +**Key Components:** -#### `query` - Server-side data fetching +- Client-side services with singleton pattern +- In-memory access tokens + HttpOnly refresh cookies +- Reactive query utilities with Svelte 5 runes +- Browser-based authentication guards + +#### Data Fetching with `createQuery` ```typescript -// tasks.remote.ts -import { query, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; +// Component: /src/routes/dashboard/tasks/+page.svelte +import { createQuery } from '$lib/utils/query.svelte'; +import { getTaskInstance } from '$lib/services'; -// Without parameters -export const getTasks = query(async () => { - const { cookies } = getRequestEvent(); - const taskService = createTaskService(cookies); - return await taskService.getAllTasks(); -}); +const taskService = getTaskInstance(); -// With parameters (requires Valibot schema) -export const getTask = query(v.number(), async (taskId: number) => { - const { cookies } = getRequestEvent(); - const taskService = createTaskService(cookies); - return await taskService.getTaskById(taskId); +const tasksQuery = createQuery(async () => { + if (!taskService) throw new Error('Service unavailable'); + const result = await taskService.getAllTasks(); + if (!result.success) throw new Error(result.error || 'Failed to fetch'); + return result.data!; }); ``` Usage in component: ```svelte - - {#if tasksQuery.error} tasksQuery.refresh()} /> {:else if tasksQuery.loading} @@ -101,83 +95,81 @@ Usage in component: {/if} ``` -#### `form` - Form submissions with progressive enhancement +**Parameterized queries:** ```typescript -// login.remote.ts -import { form, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; +import { createParameterizedQuery } from '$lib/utils/query.svelte'; -export const login = form( - v.object({ - email: v.pipe(v.string(), v.email()), - password: v.pipe(v.string(), v.nonEmpty()) - }), - async (data) => { - const { cookies } = getRequestEvent(); - // Handle login logic - return { success: true }; - } -); +const taskQuery = createParameterizedQuery(taskId, async (id) => { + if (!taskService) throw new Error('Service unavailable'); + const result = await taskService.getTaskById(id); + if (!result.success) throw new Error(result.error || 'Failed to fetch'); + return result.data!; +}); ``` -Usage: +#### Form Submissions with sveltekit-superforms ```svelte -
- - + + +
``` -#### `command` - Programmatic mutations - -```typescript -export const approveRequest = command( - v.object({ - contestId: v.pipe(v.number(), v.integer()), - userId: v.pipe(v.number(), v.integer()) - }), - async (data) => { - const { cookies } = getRequestEvent(); - const contestService = createContestService(cookies); - await contestService.approveRegistrationRequest(data.contestId, data.userId); - return { success: true }; - } -); -``` - -Usage (only from event handlers): - -```svelte - -``` - ### 2. Service Layer Pattern **All API communication goes through service classes.** Never make direct fetch calls. -Services are located in `/src/lib/services/`: +Services are located in `/src/lib/services/api/`: - `ApiService.ts` - Base HTTP client with auth & token refresh - `AuthService.ts` - Authentication operations +- `UserService.ts` - User profile management - `TaskService.ts` - Task management - `ContestService.ts` - Contest operations - `SubmissionService.ts` - Code submission handling -- `UserService.ts` - User profile management -- `AccessControlService.ts` - Access control operations - `TasksManagementService.ts` - Admin task management - `ContestsManagementService.ts` - Admin contest management +- `GroupsManagementService.ts` - Admin group management +- `AccessControlService.ts` - Access control operations +- `WorkerService.ts` - Worker status monitoring **Service pattern:** ```typescript +// /src/lib/services/api/TaskService.ts import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; import type { Task } from '$lib/dto/task'; +import type { ApiResponse } from '$lib/dto/response'; export class TaskService { constructor(private apiClient: ApiService) {} @@ -190,7 +182,7 @@ export class TaskService { }> { try { const response = await this.apiClient.get>({ - url: '/tasks/' + url: '/tasks' }); return { success: true, data: response.data, status: 200 }; } catch (error) { @@ -205,22 +197,40 @@ export class TaskService { } } } +``` + +**Service instance management (singleton pattern):** + +```typescript +// /src/lib/stores/service-instances.svelte.ts +import { browser } from '$app/environment'; +import type { TaskService } from '$lib/services/api/TaskService'; -// Factory function for dependency injection -export function createTaskService(cookies: Cookies): TaskService { - const apiClient = createApiClient(cookies); - return new TaskService(apiClient); +let taskInstance: TaskService | null = $state(null); + +export function getTaskInstance(): TaskService | null { + if (!browser) return null; + + if (!taskInstance) { + const apiClient = getApiInstance(); + if (!apiClient) return null; + taskInstance = new TaskService(apiClient); + } + + return taskInstance; } ``` -**Using services in remote functions:** +**Using services in components:** ```typescript -import { createTaskService } from '$lib/services/TaskService'; -import { getRequestEvent } from '$app/server'; +import { getTaskInstance } from '$lib/services'; -const { cookies } = getRequestEvent(); -const taskService = createTaskService(cookies); +const taskService = getTaskInstance(); +if (taskService) { + const result = await taskService.getAllTasks(); + // Handle result +} ``` ### 3. Svelte 5 Runes (No Legacy Syntax) @@ -482,14 +492,14 @@ type FormData = v.InferOutput; ## File Naming Conventions -| Type | Convention | Example | -| ---------------- | ----------------------------- | ----------------------------------- | -| Components | PascalCase | `TaskDescription.svelte` | -| Services | PascalCase + `Service` suffix | `TaskService.ts` | -| DTOs | lowercase | `task.ts`, `contest.ts` | -| Routes | SvelteKit convention | `+page.svelte`, `+layout.server.ts` | -| Remote functions | `.remote.ts` suffix | `tasks.remote.ts` | -| Utilities | camelCase | `calculateScore.ts` | +| Type | Convention | Example | +| ---------- | ----------------------------- | ----------------------------------- | +| Components | PascalCase | `TaskDescription.svelte` | +| Services | PascalCase + `Service` suffix | `TaskService.ts` | +| DTOs | lowercase | `task.ts`, `contest.ts` | +| Routes | SvelteKit convention | `+page.svelte`, `+layout.server.ts` | +| Schemas | PascalCase + `Schema` suffix | `LoginSchema`, `CreateGroupSchema` | +| Utilities | camelCase | `calculateScore.ts` | ## Common Patterns @@ -497,10 +507,18 @@ type FormData = v.InferOutput; ```svelte {#if query.error} @@ -508,21 +526,42 @@ type FormData = v.InferOutput; {:else if query.loading} {:else if query.current} -
{query.current.title}
+
{query.current[0]?.title}
{/if} ``` -### Form Submission +### Form Submission with sveltekit-superforms ```svelte -
- - + + + {#if $errors.email}{$errors.email}{/if} +
``` @@ -552,13 +591,14 @@ Before committing: ## Important Notes 1. **Always use pnpm** - NEVER use npm or yarn (will create wrong lockfiles) -2. **Always use remote functions** for server-side operations -3. **Always use services** for API calls - never direct fetch +2. **Always use services** for API calls - never direct fetch +3. **Always use createQuery/createParameterizedQuery** for data fetching in components 4. **Always add i18n to BOTH languages** - check existing messages first 5. **Use Svelte 5 runes** - no legacy reactive syntax (`$:`, stores unless necessary) 6. **Match existing design** - refer to landing page, admin pages, and task pages 7. **Validate with Valibot** - especially for user input and form schemas -8. **HTTP-only cookies for tokens** - never use localStorage/sessionStorage +8. **HTTP-only cookies for refresh tokens** - access tokens in memory only +9. **Use sveltekit-superforms in SPA mode** for form handling ## Environment Variables @@ -586,10 +626,14 @@ refactor: simplify auth service ## Key Reference Files -- Remote function examples: `/src/routes/dashboard/tasks/tasks.remote.ts` -- Form example: `/src/routes/(landing)/login/login.remote.ts` -- Service example: `/src/lib/services/TaskService.ts` -- API client: `/src/lib/services/ApiService.ts` +- Query utility: `/src/lib/utils/query.svelte.ts` +- Service examples: `/src/lib/services/api/TaskService.ts`, `/src/lib/services/api/AuthService.ts` +- Service instances: `/src/lib/stores/service-instances.svelte.ts` +- API client: `/src/lib/services/api/ApiService.ts` +- Schema examples: `/src/lib/schemas/user.ts`, `/src/lib/schemas/task.ts` +- Form example (SPA mode): `/src/lib/components/auth/ClientLoginForm.svelte` +- Auth guards: `/src/lib/auth/guards.ts` +- Component example: `/src/routes/dashboard/tasks/+page.svelte` - Landing page styling: `/src/routes/+page.svelte` - Dashboard layout: `/src/routes/dashboard/+layout.svelte` - Auth middleware: `/src/hooks.server.ts` diff --git a/README.md b/README.md index 75842c4..2372668 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 +## Architecture -If you're seeing this, you've probably already done this step. Congrats! +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. -```sh -# create a new project in the current directory -npx sv create +### Client API + +For use cases requiring direct client-to-backend communication (e.g., real-time features, SPA-like interactions): + +- **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 + +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 + +```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 +``` -# create a new project in my-app -npx sv create my-app +## Environment Variables + +```env +# Server-side (private) +BACKEND_API_URL=http://localhost:8000 + +# Client-side (public) - for direct client API usage +PUBLIC_BACKEND_API_URL=http://localhost:8000/api/v1 ``` -## Developing +## 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 `npm install` (or `pnpm install` or `yarn`), start a development server: +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,42 @@ 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 `pnpm preview`. + +## Testing + +```sh +# Type checking +pnpm check + +# Watch mode +pnpm check:watch + +# Linting +pnpm lint + +# Format code +pnpm format ``` -You can preview the production build with `npm run preview`. +## Project Structure -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +``` +src/ +├── lib/ +│ ├── auth/ # Client-side auth utilities +│ ├── components/ # Reusable components +│ │ └── auth/ # Auth-specific components +│ ├── dto/ # Data transfer objects +│ ├── services/ # API services +│ │ ├── ApiService.ts # Server-side API client +│ │ ├── ClientApiService.ts # Browser-side API client +│ │ ├── client-api-instance.ts # Global singleton instance +│ │ ├── AuthService.ts # Server-side auth +│ │ └── ClientAuthService.ts # Browser-side auth +│ └── token.ts # Token management +└── routes/ # SvelteKit routes +``` diff --git a/messages/en.json b/messages/en.json index f5bad25..1f76db9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -101,6 +101,7 @@ "admin_tasks_card_visible_to_users": "Visible to Users", "admin_tasks_dialog_description": "Upload a task archive to the FileStorage service. The archive should contain all necessary files for the task.", "admin_tasks_dialog_title": "Upload Task", + "admin_tasks_form_file_label": "Task Archive", "admin_tasks_form_archive_label": "Task Archive", "admin_tasks_form_cancel": "Cancel", "admin_tasks_form_selected_file": "Selected: {filename} ({size} MB)", @@ -135,6 +136,8 @@ "admin_tasks_upload_error": "Failed to upload task. Please try again.", "admin_tasks_upload_success": "Task uploaded successfully!", "admin_tasks_upload_title": "Upload Task", + "admin_tasks_upload_uploading": "Uploading...", + "admin_tasks_upload_button": "Upload Task", "admin_tasks_upload_limit_loading": "…", "admin_tasks_upload_limit_unavailable": "limit unavailable", "admin_tasks_upload_limit": "{limit} MB max", @@ -207,6 +210,8 @@ "error_go_home": "Go Home", "error_try_again": "Try Again", "error_unknown_error": "An unknown error occurred", + "error_loading_data": "Failed to load data", + "error_loading_tasks": "Failed to load tasks", "common_not_available": "N/A", "features_create_description": "Easily design and publish programming challenges tailored to your students' skills.", "features_create_title": "Create Algorithmic Tasks", @@ -648,6 +653,8 @@ "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!", + "register_success": "Registration successful! Welcome!", "admin_dashboard_worker_id": "Worker {id}", "admin_dashboard_worker_processing": "Processing:", "contest_results_invalid_contest_id": "Invalid contest ID", @@ -869,5 +876,11 @@ "contest_groups_remove_success": "Group removed successfully!", "contest_groups_remove_error": "Failed to remove group", "admin_contests_card_view_groups": "Manage Groups", - "sidebar_groups": "Groups" + "sidebar_groups": "Groups", + "tasks_title": "Available Tasks", + "tasks_subtitle": "Browse and work on available programming challenges", + "tasks_empty_title": "No tasks available", + "tasks_empty_description": "There are currently no tasks assigned to you", + "tasks_loading": "Loading tasks...", + "tasks_page_title": "Tasks" } diff --git a/messages/pl.json b/messages/pl.json index 1aadb07..1c14a7c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -101,6 +101,7 @@ "admin_tasks_card_visible_to_users": "Widoczne dla użytkowników", "admin_tasks_dialog_description": "Prześlij archiwum zadania do serwisu FileStorage. Archiwum powinno zawierać wszystkie niezbędne pliki zadania.", "admin_tasks_dialog_title": "Prześlij zadanie", + "admin_tasks_form_file_label": "Archiwum Zadania", "admin_tasks_form_archive_label": "Archiwum Zadania", "admin_tasks_form_cancel": "Anuluj", "admin_tasks_form_selected_file": "Wybrano: {filename} ({size} MB)", @@ -135,6 +136,8 @@ "admin_tasks_upload_error": "Nie udało się przesłać zadania. Spróbuj ponownie.", "admin_tasks_upload_success": "Zadanie zostało pomyślnie przesłane!", "admin_tasks_upload_title": "Prześlij zadanie", + "admin_tasks_upload_uploading": "Przesyłanie...", + "admin_tasks_upload_button": "Prześlij zadanie", "admin_tasks_upload_limit_loading": "…", "admin_tasks_upload_limit_unavailable": "limit niedostępny", "admin_tasks_upload_limit": "maks. {limit} MB", @@ -207,6 +210,8 @@ "error_go_home": "Strona Główna", "error_try_again": "Spróbuj Ponownie", "error_unknown_error": "Wystąpił nieznany błąd", + "error_loading_data": "Nie udało się załadować danych", + "error_loading_tasks": "Nie udało się załadować zadań", "common_not_available": "Niedostępne", "features_create_description": "Łatwo projektuj i publikuj wyzwania programistyczne dostosowane do umiejętności Twoich studentów.", "features_create_title": "Twórz Zadania Algorytmiczne", @@ -648,6 +653,8 @@ "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!", + "register_success": "Rejestracja pomyślna! Witaj!", "admin_dashboard_worker_id": "Worker {id}", "admin_dashboard_worker_processing": "Przetwarzanie:", "contest_results_invalid_contest_id": "Nieprawidłowy identyfikator konkursu", @@ -869,5 +876,11 @@ "contest_groups_remove_success": "Grupa została usunięta pomyślnie!", "contest_groups_remove_error": "Nie udało się usunąć grupy", "admin_contests_card_view_groups": "Zarządzaj Grupami", - "sidebar_groups": "Grupy" + "sidebar_groups": "Grupy", + "tasks_title": "Dostępne zadania", + "tasks_subtitle": "Przeglądaj i pracuj nad dostępnymi wyzwaniami programistycznymi", + "tasks_empty_title": "Brak dostępnych zadań", + "tasks_empty_description": "Obecnie nie masz przypisanych żadnych zadań", + "tasks_loading": "Ładowanie zadań...", + "tasks_page_title": "Zadania" } diff --git a/package.json b/package.json index 6e5dea1..e80544e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.13.0", + "formsnap": "^2.0.1", "globals": "^16.5.0", "mode-watcher": "^1.1.0", "prettier": "^3.6.2", @@ -37,6 +38,7 @@ "svelte": "^5.43.6", "svelte-check": "^4.3.4", "svelte-sonner": "^1.0.6", + "sveltekit-superforms": "^2.28.1", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.1.1", "tailwindcss": "^4.1.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d17d21..6ff771b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: eslint-plugin-svelte: specifier: ^3.13.0 version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.6) + formsnap: + specifier: ^2.0.1 + version: 2.0.1(svelte@5.43.6)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(@types/json-schema@7.0.15)(esbuild@0.25.12)(svelte@5.43.6)(typescript@5.9.3)) globals: specifier: ^16.5.0 version: 16.5.0 @@ -93,6 +96,9 @@ importers: svelte-sonner: specifier: ^1.0.6 version: 1.0.6(svelte@5.43.6) + sveltekit-superforms: + specifier: ^2.28.1 + version: 2.28.1(@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(@types/json-schema@7.0.15)(esbuild@0.25.12)(svelte@5.43.6)(typescript@5.9.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -117,6 +123,16 @@ importers: packages: + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -320,6 +336,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@finom/zod-to-json-schema@3.24.12': + resolution: {integrity: sha512-mf8CyoW+dFvsvROvHIXznrYWdmlxvBJGIeQpGJaD9iBn23kSSPiC7H0YIqgziMZJDFIzL4VEFCwpcUSHmoeNVw==} + deprecated: 'Use https://www.npmjs.com/package/zod-v3-to-json-schema instead. See issue comment for details: https://github.com/StefanTerdell/zod-to-json-schema/issues/178#issuecomment-3533122539' + peerDependencies: + zod: ^3.25 || ^4.0.14 + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -329,6 +354,16 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@gcornut/valibot-json-schema@0.42.0': + resolution: {integrity: sha512-4Et4AN6wmqeA0PfU5Clkv/IS27wiefsWf6TemAZrb75uzkClYEFavim7SboeKwbll9Nbsn2Iv0LT/HS5H7orZg==} + hasBin: true + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -402,6 +437,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/macroable@1.1.0': + resolution: {integrity: sha512-y/YKzZDuG8XrpXpM7Z1RdQpiIc0MAKyva24Ux1PB4aI7RiSI/79K8JVDcdyubriTm7vJ1LhFs8CrZpmPnx/8Pw==} + '@rollup/plugin-commonjs@28.0.9': resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -548,6 +586,15 @@ packages: cpu: [x64] os: [win32] + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@sinclair/typebox@0.31.28': resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} @@ -704,6 +751,25 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@typeschema/class-validator@0.3.0': + resolution: {integrity: sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==} + peerDependencies: + class-validator: ^0.14.1 + peerDependenciesMeta: + class-validator: + optional: true + + '@typeschema/core@0.14.0': + resolution: {integrity: sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==} + peerDependencies: + '@types/json-schema': ^7.0.15 + peerDependenciesMeta: + '@types/json-schema': + optional: true + '@typescript-eslint/eslint-plugin@8.46.4': resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -763,6 +829,14 @@ packages: resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vinejs/compiler@3.0.0': + resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==} + engines: {node: '>=18.0.0'} + + '@vinejs/vine@3.0.1': + resolution: {integrity: sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==} + engines: {node: '>=18.16.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -787,6 +861,12 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + arkregex@0.0.4: + resolution: {integrity: sha512-biS/FkvSwQq59TZ453piUp8bxMui11pgOMV9WHAnli1F8o0ayNCZzUwQadL/bGIUic5TkS/QlPcyMuI8ZIwedQ==} + + arktype@2.1.28: + resolution: {integrity: sha512-LVZqXl2zWRpNFnbITrtFmqeqNkPPo+KemuzbGSY6jvJwCb4v8NsDzrWOLHnQgWl26TkJeWWcUNUeBpq2Mst1/Q==} + array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -814,10 +894,17 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -826,6 +913,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + class-validator@0.14.3: + resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -874,6 +964,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -909,10 +1002,22 @@ packages: devalue@5.5.0: resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + effect@3.19.9: + resolution: {integrity: sha512-taMXnfG/p+j7AmMOHHQaCHvjqwu9QBO3cxuZqL2dMG/yWcEMw0ZHruHe9B49OxtfKH/vKKDDKRhZ+1GJ2p5R5w==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + esbuild-runner@2.2.2: + resolution: {integrity: sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==} + hasBin: true + peerDependencies: + esbuild: '*' + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -994,6 +1099,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1038,6 +1147,13 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + formsnap@2.0.1: + resolution: {integrity: sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.0.0 + sveltekit-superforms: ^2.19.0 + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1135,6 +1251,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@6.1.1: resolution: {integrity: sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==} @@ -1148,6 +1267,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1177,6 +1300,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.31: + resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1268,6 +1394,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + memoize-weak@1.0.2: + resolution: {integrity: sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1307,6 +1436,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + normalize-url@8.1.0: + resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} + engines: {node: '>=14.16'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1453,10 +1586,16 @@ packages: engines: {node: '>=14'} hasBin: true + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1537,6 +1676,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + sqlite-wasm-kysely@0.3.0: resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} peerDependencies: @@ -1549,6 +1695,10 @@ packages: style-to-object@1.0.12: resolution: {integrity: sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==} + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1585,6 +1735,12 @@ packages: peerDependencies: svelte: ^5.30.2 + svelte-toolbelt@0.5.0: + resolution: {integrity: sha512-t3tenZcnfQoIeRuQf/jBU7bvTeT3TGkcEE+1EUr5orp0lR7NEpprflpuie3x9Dn0W9nOKqs3HwKGJeeN5Ok1sQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.0.0-next.126 + svelte-toolbelt@0.7.1: resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -1595,6 +1751,12 @@ packages: resolution: {integrity: sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA==} engines: {node: '>=18'} + sveltekit-superforms@2.28.1: + resolution: {integrity: sha512-b7QOVpPGhTS/5m9Bli71lTePtd/GI/bSBp+UoJ+raWg9z/qfRLmM3qzOUyb6OFD2X0xZP5APEUeZKpfdt1SVAQ==} + peerDependencies: + '@sveltejs/kit': 1.x || 2.x + svelte: 3.x || 4.x || >=5.0.0-next.51 + tabbable@6.3.0: resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} @@ -1618,6 +1780,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1626,16 +1791,29 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + ts-deepmerge@7.0.3: + resolution: {integrity: sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==} + engines: {node: '>=14.13.1'} + + tslib@2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1646,6 +1824,13 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typebox@1.0.61: + resolution: {integrity: sha512-5KeeL5QoPBoYm8Z7tGR1Pw9FjWA75MLhVuiSMCRgtgTg/d2+kTvolFddhOUua9FxpIaqXznFPZcc3sl6cEpafw==} + typescript-eslint@8.46.4: resolution: {integrity: sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1678,6 +1863,14 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + valibot@0.42.1: + resolution: {integrity: sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -1686,6 +1879,10 @@ packages: typescript: optional: true + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + engines: {node: '>= 0.10'} + vite@7.2.2: resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1754,11 +1951,28 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + snapshots: + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + optional: true + + '@ark/util@0.56.0': + optional: true + + '@babel/runtime@7.28.4': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1889,6 +2103,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/schemasafe@1.3.0': + optional: true + + '@finom/zod-to-json-schema@3.24.12(zod@4.1.13)': + dependencies: + zod: 4.1.13 + optional: true + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -1900,6 +2122,25 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@gcornut/valibot-json-schema@0.42.0(esbuild@0.25.12)(typescript@5.9.3)': + dependencies: + valibot: 0.42.1(typescript@5.9.3) + optionalDependencies: + '@types/json-schema': 7.0.15 + esbuild-runner: 2.2.2(esbuild@0.25.12) + transitivePeerDependencies: + - esbuild + - typescript + optional: true + + '@hapi/hoek@9.3.0': + optional: true + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + optional: true + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -1992,6 +2233,9 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@poppinss/macroable@1.1.0': + optional: true + '@rollup/plugin-commonjs@28.0.9(rollup@4.53.2)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.2) @@ -2094,6 +2338,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + optional: true + + '@sideway/formula@3.0.1': + optional: true + + '@sideway/pinpoint@2.0.0': + optional: true + '@sinclair/typebox@0.31.28': {} '@sqlite.org/sqlite-wasm@3.48.0-build4': {} @@ -2236,6 +2491,23 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/validator@13.15.10': + optional: true + + '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.3)': + dependencies: + '@typeschema/core': 0.14.0(@types/json-schema@7.0.15) + optionalDependencies: + class-validator: 0.14.3 + transitivePeerDependencies: + - '@types/json-schema' + optional: true + + '@typeschema/core@0.14.0(@types/json-schema@7.0.15)': + optionalDependencies: + '@types/json-schema': 7.0.15 + optional: true + '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2329,6 +2601,21 @@ snapshots: '@typescript-eslint/types': 8.46.4 eslint-visitor-keys: 4.2.1 + '@vinejs/compiler@3.0.0': + optional: true + + '@vinejs/vine@3.0.1': + dependencies: + '@poppinss/macroable': 1.1.0 + '@types/validator': 13.15.10 + '@vinejs/compiler': 3.0.0 + camelcase: 8.0.0 + dayjs: 1.11.19 + dlv: 1.1.3 + normalize-url: 8.1.0 + validator: 13.15.23 + optional: true + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2350,6 +2637,18 @@ snapshots: aria-query@5.3.2: {} + arkregex@0.0.4: + dependencies: + '@ark/util': 0.56.0 + optional: true + + arktype@2.1.28: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.4 + optional: true + array-timsort@1.0.3: {} axobject-query@4.1.0: {} @@ -2382,8 +2681,14 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-from@1.1.2: + optional: true + callsites@3.1.0: {} + camelcase@8.0.0: + optional: true + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2393,6 +2698,13 @@ snapshots: dependencies: readdirp: 4.1.2 + class-validator@0.14.3: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.31 + validator: 13.15.23 + optional: true + clsx@2.1.1: {} color-convert@2.0.1: @@ -2429,6 +2741,9 @@ snapshots: date-fns@4.1.0: {} + dayjs@1.11.19: + optional: true + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2445,11 +2760,27 @@ snapshots: devalue@5.5.0: {} + dlv@1.1.3: + optional: true + + effect@3.19.9: + dependencies: + '@standard-schema/spec': 1.0.0 + fast-check: 3.23.2 + optional: true + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 + esbuild-runner@2.2.2(esbuild@0.25.12): + dependencies: + esbuild: 0.25.12 + source-map-support: 0.5.21 + tslib: 2.4.0 + optional: true + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2581,6 +2912,11 @@ snapshots: esutils@2.0.3: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + optional: true + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2623,6 +2959,12 @@ snapshots: flatted@3.3.3: {} + formsnap@2.0.1(svelte@5.43.6)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(@types/json-schema@7.0.15)(esbuild@0.25.12)(svelte@5.43.6)(typescript@5.9.3)): + dependencies: + svelte: 5.43.6 + svelte-toolbelt: 0.5.0(svelte@5.43.6) + sveltekit-superforms: 2.28.1(@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(@types/json-schema@7.0.15)(esbuild@0.25.12)(svelte@5.43.6)(typescript@5.9.3) + fsevents@2.3.3: optional: true @@ -2693,6 +3035,15 @@ snapshots: jiti@2.6.1: {} + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + optional: true + jose@6.1.1: {} js-sha256@0.11.1: {} @@ -2703,6 +3054,12 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + optional: true + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -2724,6 +3081,9 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.31: + optional: true + lightningcss-android-arm64@1.30.2: optional: true @@ -2789,6 +3149,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + memoize-weak@1.0.2: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2820,6 +3182,9 @@ snapshots: natural-compare@1.4.0: {} + normalize-url@8.1.0: + optional: true + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2894,8 +3259,14 @@ snapshots: prettier@3.6.2: {} + property-expr@2.0.6: + optional: true + punycode@2.3.1: {} + pure-rand@6.1.0: + optional: true + queue-microtask@1.2.3: {} readdirp@4.1.2: {} @@ -2988,6 +3359,15 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + + source-map@0.6.1: + optional: true + sqlite-wasm-kysely@0.3.0(kysely@0.27.6): dependencies: '@sqlite.org/sqlite-wasm': 3.48.0-build4 @@ -2999,6 +3379,9 @@ snapshots: dependencies: inline-style-parser: 0.2.6 + superstruct@2.0.2: + optional: true + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3042,6 +3425,12 @@ snapshots: transitivePeerDependencies: - '@sveltejs/kit' + svelte-toolbelt@0.5.0(svelte@5.43.6): + dependencies: + clsx: 2.1.1 + style-to-object: 1.0.12 + svelte: 5.43.6 + svelte-toolbelt@0.7.1(svelte@5.43.6): dependencies: clsx: 2.1.1 @@ -3066,6 +3455,34 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 + sveltekit-superforms@2.28.1(@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(@types/json-schema@7.0.15)(esbuild@0.25.12)(svelte@5.43.6)(typescript@5.9.3): + dependencies: + '@sveltejs/kit': 2.48.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.43.6)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) + devalue: 5.5.0 + memoize-weak: 1.0.2 + svelte: 5.43.6 + ts-deepmerge: 7.0.3 + optionalDependencies: + '@exodus/schemasafe': 1.3.0 + '@finom/zod-to-json-schema': 3.24.12(zod@4.1.13) + '@gcornut/valibot-json-schema': 0.42.0(esbuild@0.25.12)(typescript@5.9.3) + '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.3) + '@vinejs/vine': 3.0.1 + arktype: 2.1.28 + class-validator: 0.14.3 + effect: 3.19.9 + joi: 17.13.3 + json-schema-to-ts: 3.1.1 + superstruct: 2.0.2 + typebox: 1.0.61 + valibot: 1.1.0(typescript@5.9.3) + yup: 1.7.1 + zod: 4.1.13 + transitivePeerDependencies: + - '@types/json-schema' + - esbuild + - typescript + tabbable@6.3.0: {} tailwind-merge@3.4.0: {} @@ -3080,6 +3497,9 @@ snapshots: tapable@2.3.0: {} + tiny-case@1.0.3: + optional: true + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -3089,12 +3509,23 @@ snapshots: dependencies: is-number: 7.0.0 + toposort@2.0.2: + optional: true + totalist@3.0.1: {} + ts-algebra@2.0.0: + optional: true + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 + ts-deepmerge@7.0.3: {} + + tslib@2.4.0: + optional: true + tslib@2.8.1: {} tw-animate-css@1.4.0: {} @@ -3103,6 +3534,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: + optional: true + + typebox@1.0.61: + optional: true + typescript-eslint@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) @@ -3135,10 +3572,18 @@ snapshots: uuid@10.0.0: {} + valibot@0.42.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + optional: true + valibot@1.1.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 + validator@13.15.23: + optional: true + vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.12 @@ -3169,4 +3614,15 @@ snapshots: yocto-queue@0.1.0: {} + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + optional: true + zimmerframe@1.1.4: {} + + zod@4.1.13: + optional: true diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 159a231..33a86c6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,12 +1,19 @@ 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 { TokenManager } from '$lib/token'; import { decodeAccessToken } from '$lib/jwt'; +const handleAuth: Handle = async ({ event, resolve }) => { + if (event.url.pathname.includes('/dashboard')) { + const token = TokenManager.getAccessToken(event.cookies); + event.locals.user = token ? decodeAccessToken(token) : null; + } else { + event.locals.user = null; + } + + return resolve(event); +}; + const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { event.request = request; @@ -16,32 +23,9 @@ 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 = async ({ event, resolve }) => { + return handleParaglide({ + event, + resolve: (innerEvent) => handleAuth({ event: innerEvent, resolve }) + }); }; - -export const handle: Handle = sequence(handleParaglide, handleAuth); diff --git a/src/lib/auth/client-logout.ts b/src/lib/auth/client-logout.ts new file mode 100644 index 0000000..20a8ec3 --- /dev/null +++ b/src/lib/auth/client-logout.ts @@ -0,0 +1,35 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import { AppRoutes } from '$lib/routes'; +import { getAuthInstance } 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 authService = getAuthInstance(); + if (!authService) { + console.error('Auth service not available'); + return; + } + + 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/auth/guards.ts b/src/lib/auth/guards.ts new file mode 100644 index 0000000..39305b9 --- /dev/null +++ b/src/lib/auth/guards.ts @@ -0,0 +1,122 @@ +import { browser } from '$app/environment'; +import { redirect } from '@sveltejs/kit'; +import { tokenStore } from '$lib/stores/token-store.svelte'; +import { userStore } from '$lib/stores/user-store.svelte'; +import { getApiInstance, getUserInstance } from '$lib/stores/service-instances.svelte'; +import { AppRoutes } from '$lib/routes'; + +/** + * Client-side authentication guard + * Ensures user is authenticated before accessing protected routes + * Performs silent refresh if no access token is present + * Fetches user data if not already loaded + * + * @param url - Current URL being accessed + * @throws redirect - Redirects to login if authentication fails + * + * @example + * ```typescript + * // In +layout.ts + * export const load: LayoutLoad = async ({ url }) => { + * await requireAuth(url); + * return {}; + * }; + * ``` + */ +export async function requireAuth(url: URL): Promise { + // Skip auth check during SSR - will be handled on client-side + if (!browser) { + return; + } + + // Check if we have an access token + if (!tokenStore.hasToken()) { + // No token - try silent refresh using HTTP-only refresh cookie + const apiClient = getApiInstance(); + if (apiClient) { + try { + const refreshed = await apiClient.silentRefresh(); + if (!refreshed) { + // Silent refresh failed - redirect to login with return URL + const redirectTo = url.pathname + url.search; + throw redirect(303, `${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + } + } catch (error) { + // Silent refresh error - redirect to login + const redirectTo = url.pathname + url.search; + throw redirect(303, `${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + } + } else { + // No API client available - redirect to login + throw redirect(303, AppRoutes.Login); + } + } + + // Token exists - fetch user data if not already loaded + if (!userStore.tryGetUser() && !userStore.isLoading()) { + const userService = getUserInstance(); + if (userService) { + try { + const result = await userService.getCurrentUser(); + if (!result.success) { + // Failed to fetch user - clear token and redirect + tokenStore.clearAccessToken(); + const redirectTo = url.pathname + url.search; + throw redirect(303, `${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + } + } catch (error) { + // Error fetching user - clear token and redirect + console.error('Failed to fetch user:', error); + tokenStore.clearAccessToken(); + throw redirect(303, AppRoutes.Login); + } + } + } +} + +/** + * Optional authentication guard + * Attempts to load user data if token exists, but doesn't require authentication + * Useful for pages that work for both authenticated and unauthenticated users + * + * @example + * ```typescript + * // In +layout.ts for public pages + * export const load: LayoutLoad = async () => { + * await optionalAuth(); + * return {}; + * }; + * ``` + */ +export async function optionalAuth(): Promise { + if (!browser) { + return; + } + + // Try silent refresh if no token + if (!tokenStore.hasToken()) { + const apiClient = getApiInstance(); + if (apiClient) { + try { + await apiClient.silentRefresh(); + } catch (error) { + // Silent refresh failed - user remains unauthenticated + console.debug('Silent refresh failed:', error); + } + } + } + + // If token exists, try to fetch user + if (tokenStore.hasToken() && !userStore.tryGetUser() && !userStore.isLoading()) { + const userService = getUserInstance(); + if (userService) { + try { + await userService.getCurrentUser(); + } catch (error) { + // Failed to fetch user - clear invalid token + console.debug('Failed to fetch user:', error); + tokenStore.clearAccessToken(); + } + } + } +} diff --git a/src/lib/components/auth/ClientLoginForm.svelte b/src/lib/components/auth/ClientLoginForm.svelte new file mode 100644 index 0000000..e534e96 --- /dev/null +++ b/src/lib/components/auth/ClientLoginForm.svelte @@ -0,0 +1,121 @@ + + +
+
+ + + {#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..f74e615 --- /dev/null +++ b/src/lib/components/auth/ClientLogoutButton.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/lib/components/auth/ClientRegisterForm.svelte b/src/lib/components/auth/ClientRegisterForm.svelte new file mode 100644 index 0000000..5bb4afa --- /dev/null +++ b/src/lib/components/auth/ClientRegisterForm.svelte @@ -0,0 +1,188 @@ + + +
+
+ + + {#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 new file mode 100644 index 0000000..fcb24c5 --- /dev/null +++ b/src/lib/components/auth/index.ts @@ -0,0 +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/lib/components/dashboard/DashboardSidebar.svelte b/src/lib/components/dashboard/DashboardSidebar.svelte index 3b42d60..ede1305 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 { getAuthInstance } 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(), @@ -247,16 +246,21 @@ {#snippet child({ props })} -
{ - await submit(); - })} + -
+ + {m.sidebar_logout()} + {/snippet}
diff --git a/src/lib/components/dashboard/admin/contests/AddContestCollaboratorButton.svelte b/src/lib/components/dashboard/admin/contests/AddContestCollaboratorButton.svelte index fe97ec3..bba95dc 100644 --- a/src/lib/components/dashboard/admin/contests/AddContestCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/contests/AddContestCollaboratorButton.svelte @@ -7,23 +7,27 @@ import UserPlus from '@lucide/svelte/icons/user-plus'; import Search from '@lucide/svelte/icons/search'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; - import { Permission } from '$lib/dto/accessControl'; + import { Permission, ResourceType } from '$lib/dto/accessControl'; import type { User } from '$lib/dto/user'; - import type { AddCollaboratorForm } from '$routes/dashboard/teacher/contests/[contestId]/collaborators/collaborators.remote'; import { LoadingSpinner } from '$lib/components/common'; import type { PaginatedData } from '$lib/dto/response'; + import { getAccessControlInstance } from '$lib/services'; + import { AddCollaboratorSchema } from '$lib/schemas'; + import { defaults, superForm } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; interface Props { contestId: number; - addCollaborator: AddCollaboratorForm; users: PaginatedData | undefined; usersLoading: boolean; usersError: Error | null; + onSuccess?: () => void; } - let { contestId, addCollaborator, users, usersLoading, usersError }: Props = $props(); + let { contestId, users, usersLoading, usersError, onSuccess }: Props = $props(); + + const accessControlService = getAccessControlInstance(); let dialogOpen = $state(false); let searchQuery = $state(''); @@ -48,6 +52,37 @@ let selectedUser = $derived(users?.items.find((u) => u.id === selectedUserId)); + const { form, errors, enhance, submitting } = superForm( + defaults( + { resourceId: contestId, userId: 0, permission: Permission.Edit }, + valibot(AddCollaboratorSchema) + ), + { + id: `add-contest-collab-${contestId}`, + validators: valibot(AddCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.addCollaborator(ResourceType.Contests, form.data.resourceId, { + userId: form.data.userId, + permission: form.data.permission as Permission.Edit | Permission.Manage + }); + toast.success(m.contest_collaborators_add_success()); + dialogOpen = false; + resetForm(); + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Add contest collaborator error:', error); + toast.error(m.contest_collaborators_add_error()); + } + } + } + ); + function resetForm() { searchQuery = ''; selectedUserId = null; @@ -107,28 +142,7 @@ {:else if usersLoading} {:else} -
{ - try { - await submit(); - toast.success(m.contest_collaborators_add_success()); - dialogOpen = false; - resetForm(); - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.contest_collaborators_add_error()); - } - } - })} - class="space-y-6" - > - - - - - +
@@ -157,7 +171,10 @@ {#each filteredUsers as user (user.id)} diff --git a/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte b/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte index d864671..6a5ed80 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,20 @@
- - diff --git a/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte b/src/lib/components/dashboard/admin/contests/ContestCollaboratorPermissionEditor.svelte index 6f5dea3..4b0fc9a 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,52 @@ 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 +108,7 @@ // Different permission selected, show confirmation dialog selectedPermission = permission; + $form.permission = permission; popoverOpen = false; dialogOpen = true; } @@ -151,35 +188,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..4e6dae4 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 || null, + 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,39 @@ -
{ - 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 +234,9 @@ />
- {#each createContest.fields.startAt.issues() as issue (issue.message)} -

{issue.message}

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

{$errors.startAt}

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

{issue.message}

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

{$errors.endAt}

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

{issue.message}

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

{$errors.endAt}

+ {/if} {/if} @@ -252,8 +284,10 @@
{#if groupQuery.current} - + groupQuery.refresh()} + /> {/if} diff --git a/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte index 9628acc..3e706c9 100644 --- a/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte +++ b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte @@ -1,12 +1,7 @@
@@ -47,7 +67,7 @@ icon={Upload} /> {:else if tasksQuery.current} - + tasksQuery.refresh()} /> {/if}
diff --git a/src/routes/dashboard/teacher/tasks/[taskId]/collaborators/+page.svelte b/src/routes/dashboard/teacher/tasks/[taskId]/collaborators/+page.svelte index de28d4a..99e2e8a 100644 --- a/src/routes/dashboard/teacher/tasks/[taskId]/collaborators/+page.svelte +++ b/src/routes/dashboard/teacher/tasks/[taskId]/collaborators/+page.svelte @@ -1,12 +1,7 @@ console.error('Contest page error:', error)}> diff --git a/src/routes/dashboard/user/contests/[contestId]/+page.svelte b/src/routes/dashboard/user/contests/[contestId]/+page.svelte index 1fa3788..fe08d9f 100644 --- a/src/routes/dashboard/user/contests/[contestId]/+page.svelte +++ b/src/routes/dashboard/user/contests/[contestId]/+page.svelte @@ -7,18 +7,23 @@ import Trophy from '@lucide/svelte/icons/trophy'; import Target from '@lucide/svelte/icons/target'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import { getContestTasksWithStatistics } from './contest.remote'; + import { page } from '$app/state'; + import { createParameterizedQuery } from '$lib/utils/query.svelte'; + import { getContestInstance } from '$lib/services'; import { AppRoutes } from '$lib/routes'; - interface Props { - data: { - contestId: number; - }; - } + const contestService = getContestInstance(); + const contestId = $derived(Number(page.params.contestId)); - let { data }: Props = $props(); - - const tasksQuery = getContestTasksWithStatistics(data.contestId); + const tasksQuery = createParameterizedQuery( + () => contestId, + async (id) => { + if (!contestService) throw new Error('Service unavailable'); + const result = await contestService.getContestTasksWithStatistics(id); + if (!result.success) throw new Error(result.error || 'Failed to fetch tasks'); + return result.data!; + } + );
@@ -86,7 +91,7 @@