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 - - - Submit + + + Submit ``` -#### `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 - approveRequest({ contestId: 123, userId: 456 })}> Approve -``` - ### 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 - - - Submit + + + {#if $errors.email}{$errors.email}{/if} + Submit ``` @@ -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 @@ + + + + + {m.login_email_label()} + + {#if $errors.email} + {$errors.email} + {/if} + + + + + {m.login_password_label()} + + {m.login_forgot_password()} + + + + {#if $errors.password} + {$errors.password} + {/if} + + + + {$submitting ? 'Loading...' : m.login_submit()} + + 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 @@ + + + + {#if children} + {@render children()} + {:else} + {isLoading ? 'Logging out...' : 'Logout'} + {/if} + 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 @@ + + + + + {m.register_email_label()} + + {#if $errors.email} + {$errors.email} + {/if} + + + + {m.register_name_label()} + + {#if $errors.name} + {$errors.name} + {/if} + + + + {m.register_surname_label()} + + {#if $errors.surname} + {$errors.surname} + {/if} + + + + {m.register_username_label()} + + {#if $errors.username} + {$errors.username} + {/if} + + + + {m.register_password_label()} + + {#if $errors.password} + {$errors.password} + {/if} + + + + {m.register_confirm_password_label()} + + {#if $errors.confirmPassword} + {$errors.confirmPassword} + {/if} + + + + {$submitting ? 'Loading...' : m.register_submit()} + + 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(); - })} + { + if (!browser) return; + const authService = getAuthInstance(); + if (authService) { + await authService.logout(); + goto(AppRoutes.Login); + } + }} > - - - {m.sidebar_logout()} - - + + {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" - > - - - - - + {m.contest_collaborators_add_user_label()} @@ -157,7 +171,10 @@ {#each filteredUsers as user (user.id)} (selectedUserId = user.id)} + onclick={() => { + selectedUserId = user.id; + $form.userId = user.id; + }} class="flex w-full items-center gap-3 border-b border-border p-3 text-left transition-colors last:border-b-0 hover:bg-muted/50 {selectedUserId === user.id ? 'bg-primary/10' @@ -200,7 +217,10 @@ {m.contest_collaborators_add_permission_label()} (selectedPermission = value as Permission)} + onValueChange={(value) => { + selectedPermission = value as Permission; + $form.permission = value as Permission; + }} > {selectedPermission @@ -226,15 +246,16 @@ dialogOpen = false; resetForm(); }} + disabled={$submitting} > {m.contest_collaborators_add_cancel()} - {m.contest_collaborators_add_submit()} + {$submitting ? 'Adding...' : m.contest_collaborators_add_submit()} 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 @@ - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={submitting} + > {m.contest_groups_add_cancel()} - - {m.contest_groups_add_submit()} + + {submitting ? 'Adding...' : m.contest_groups_add_submit()} 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; - } - })} - > - - - - + - + {m.contest_collaborators_update_cancel()} - - {m.contest_collaborators_update_confirm()} + + {$submitting ? 'Updating...' : m.contest_collaborators_update_confirm()} 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" - > - - - - + {m.admin_contests_form_name_label()} - {#each createContest.fields.name.issues() as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.name} + {$errors.name} + {/if} {m.admin_contests_form_description_label()} - {#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 @@ {m.admin_contests_form_registration_open()} @@ -279,7 +314,9 @@ {m.admin_contests_form_submission_open()} @@ -287,7 +324,12 @@ - + {m.admin_contests_form_visible()} @@ -298,17 +340,17 @@ { - dialogOpen = false; - }} + onclick={() => (dialogOpen = false)} + disabled={$submitting} > {m.admin_contests_form_cancel()} - {m.admin_contests_form_create()} + {$submitting ? 'Creating...' : m.admin_contests_form_create()} diff --git a/src/lib/components/dashboard/admin/contests/EditContestDialog.svelte b/src/lib/components/dashboard/admin/contests/EditContestDialog.svelte index 26fd5e6..e8491c2 100644 --- a/src/lib/components/dashboard/admin/contests/EditContestDialog.svelte +++ b/src/lib/components/dashboard/admin/contests/EditContestDialog.svelte @@ -8,7 +8,6 @@ import * as Popover from '$lib/components/ui/popover'; 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, @@ -16,16 +15,67 @@ getLocalTimeZone, parseDate } from '@internationalized/date'; - import { updateContest } from '$routes/dashboard/teacher/contests/contests.remote'; import { cn, toLocalRFC3339 } from '$lib/utils'; import type { CreatedContest } from '$lib/dto/contest'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { UpdateContestSchema } from '$lib/schemas'; + import { getContestsManagementInstance } from '$lib/services'; interface Props { contest: CreatedContest; dialogOpen: boolean; + onSuccess?: () => void; } - let { contest, dialogOpen = $bindable() }: Props = $props(); + let { contest, dialogOpen = $bindable(), onSuccess }: Props = $props(); + + const contestsService = getContestsManagementInstance(); + + // Initialize superform for SPA mode with client-side validation + const { form, errors, enhance, submitting } = superForm( + defaults( + { + id: contest.id, + name: contest.name, + description: contest.description, + startAt: contest.startAt ?? '', + endAt: contest.endAt ?? '', + isRegistrationOpen: contest.isRegistrationOpen, + isSubmissionOpen: contest.isSubmissionOpen, + isVisible: contest.isVisible + }, + valibot(UpdateContestSchema) + ), + { + id: `edit-contest-${contest.id}`, + validators: valibot(UpdateContestSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!contestsService || !form.valid) return; + + try { + await contestsService.updateContest(form.data.id, { + 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_edit_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Update contest error:', error); + toast.error(m.admin_contests_edit_error()); + } + } + } + ); // Date formatters const df = new DateFormatter('en-US', { @@ -71,8 +121,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) : ''; + }); @@ -84,60 +137,39 @@ - { - try { - await submit(); - toast.success(m.admin_contests_edit_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.admin_contests_edit_error()); - } - } - })} - class="space-y-6" - > - - - - - + {m.admin_contests_form_name_label()} - {#each updateContest.fields.name.issues() ?? [] as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.name} + {$errors.name} + {/if} {m.admin_contests_form_description_label()} - {#each updateContest.fields.description.issues() ?? [] as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.description} + {$errors.description} + {/if} @@ -185,9 +217,9 @@ /> - {#each updateContest.fields.startAt.issues() ?? [] as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.startAt} + {$errors.startAt} + {/if} @@ -240,9 +272,9 @@ /> - {#each updateContest.fields.endAt.issues() ?? [] as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.endAt} + {$errors.endAt} + {/if} {/if} @@ -252,8 +284,10 @@ {m.admin_contests_form_registration_open()} @@ -262,8 +296,10 @@ {m.admin_contests_form_submission_open()} @@ -272,8 +308,10 @@ {m.admin_contests_form_visible()} @@ -285,17 +323,17 @@ { - dialogOpen = false; - }} + onclick={() => (dialogOpen = false)} + disabled={$submitting} > {m.admin_contests_form_cancel()} - {m.admin_contests_form_update()} + {$submitting ? 'Updating...' : m.admin_contests_form_update()} diff --git a/src/lib/components/dashboard/admin/contests/RemoveContestCollaboratorButton.svelte b/src/lib/components/dashboard/admin/contests/RemoveContestCollaboratorButton.svelte index 54dd2fb..fbb75fc 100644 --- a/src/lib/components/dashboard/admin/contests/RemoveContestCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/contests/RemoveContestCollaboratorButton.svelte @@ -3,10 +3,9 @@ import { Button } from '$lib/components/ui/button'; import X from '@lucide/svelte/icons/x'; 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 { RemoveCollaboratorForm } from '$routes/dashboard/teacher/contests/[contestId]/collaborators/collaborators.remote'; + import { Permission, ResourceType } from '$lib/dto/accessControl'; + import { getAccessControlInstance } from '$lib/services'; interface Props { contestId: number; @@ -14,17 +13,13 @@ userName: string; targetPermission: Permission; currentUserPermission: Permission; - removeCollaborator: RemoveCollaboratorForm; + onSuccess?: () => void; } - let { - contestId, - userId, - userName, - targetPermission, - currentUserPermission, - removeCollaborator - }: Props = $props(); + let { contestId, userId, userName, targetPermission, currentUserPermission, onSuccess }: Props = + $props(); + + const accessControlService = getAccessControlInstance(); let dialogOpen = $state(false); let isRemoving = $state(false); @@ -55,6 +50,28 @@ function handleCancel() { dialogOpen = false; } + + async function handleRemove(event: Event) { + event.preventDefault(); + + if (!accessControlService) { + toast.error(m.contest_collaborators_remove_error()); + return; + } + + isRemoving = true; + try { + await accessControlService.deleteCollaborator(ResourceType.Contests, contestId, userId); + toast.success(m.contest_collaborators_remove_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove contest collaborator error:', error); + toast.error(m.contest_collaborators_remove_error()); + } finally { + isRemoving = false; + } + } {#if canRemove} @@ -77,33 +94,13 @@ - { - isRemoving = true; - try { - await submit(); - toast.success(m.contest_collaborators_remove_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.contest_collaborators_remove_error()); - } - } finally { - isRemoving = false; - } - })} - > - - - + {m.contest_collaborators_remove_cancel()} - {m.contest_collaborators_remove_confirm()} + {isRemoving ? 'Removing...' : m.contest_collaborators_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte b/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte index 4c4e8cf..b4a5875 100644 --- a/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte +++ b/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte @@ -4,25 +4,39 @@ import X from '@lucide/svelte/icons/x'; import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages'; - import { removeGroupsFromContest } from '$routes/dashboard/teacher/contests/[contestId]/groups/groups.remote'; + import { getContestsManagementInstance } from '$lib/services'; interface Props { contestId: number; groupId: number; groupName: string; + onSuccess?: () => void; } - let { contestId, groupId, groupName }: Props = $props(); + let { contestId, groupId, groupName, onSuccess }: Props = $props(); let dialogOpen = $state(false); + let submitting = $state(false); + + const contestsService = getContestsManagementInstance(); async function handleRemove() { + if (!contestsService) { + toast.error(m.contest_groups_remove_error()); + return; + } + + submitting = true; try { - await removeGroupsFromContest({ contestId, groupIds: [groupId] }); + await contestsService.removeGroupsFromContest(contestId, [groupId]); toast.success(m.contest_groups_remove_success()); dialogOpen = false; - } catch { + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove group from contest error:', error); toast.error(m.contest_groups_remove_error()); + } finally { + submitting = false; } } @@ -41,11 +55,16 @@ - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={submitting} + > {m.contest_groups_remove_cancel()} - - {m.contest_groups_remove_confirm()} + + {submitting ? 'Removing...' : m.contest_groups_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/contests/RemoveTaskFromContestButton.svelte b/src/lib/components/dashboard/admin/contests/RemoveTaskFromContestButton.svelte index 8fc3a40..4c898a4 100644 --- a/src/lib/components/dashboard/admin/contests/RemoveTaskFromContestButton.svelte +++ b/src/lib/components/dashboard/admin/contests/RemoveTaskFromContestButton.svelte @@ -3,18 +3,19 @@ import { Button } from '$lib/components/ui/button'; import Trash2 from '@lucide/svelte/icons/trash-2'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; - import type { RemoveTaskFromContestForm } from '$routes/dashboard/teacher/contests/[contestId]/tasks/tasks.remote'; + import { getContestsManagementInstance } from '$lib/services'; interface Props { contestId: number; taskId: number; taskTitle: string; - removeTaskFromContest: RemoveTaskFromContestForm; + onSuccess?: () => void; } - let { contestId, taskId, taskTitle, removeTaskFromContest }: Props = $props(); + let { contestId, taskId, taskTitle, onSuccess }: Props = $props(); + + const contestsService = getContestsManagementInstance(); let dialogOpen = $state(false); let isRemoving = $state(false); @@ -22,6 +23,28 @@ function handleCancel() { dialogOpen = false; } + + async function handleRemove(event: Event) { + event.preventDefault(); + + if (!contestsService) { + toast.error(m.admin_contest_tasks_remove_error()); + return; + } + + isRemoving = true; + try { + await contestsService.removeTaskFromContest(contestId, [taskId]); + toast.success(m.admin_contest_tasks_remove_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove task from contest error:', error); + toast.error(m.admin_contest_tasks_remove_error()); + } finally { + isRemoving = false; + } + } - { - isRemoving = true; - try { - await submit(); - toast.success(m.admin_contest_tasks_remove_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.admin_contest_tasks_remove_error()); - } - } finally { - isRemoving = false; - } - })} - > - - - + {m.admin_contest_tasks_remove_cancel()} - {m.admin_contest_tasks_remove_confirm()} + {isRemoving ? 'Removing...' : m.admin_contest_tasks_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte b/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte index ebba2e2..02ad1f6 100644 --- a/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.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/groups/[groupId]/collaborators/collaborators.remote'; + import { getAccessControlInstance } from '$lib/services'; import { LoadingSpinner } from '$lib/components/common'; import type { PaginatedData } from '$lib/dto/response'; + import { AddCollaboratorSchema } from '$lib/schemas'; + import { defaults, superForm } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; interface Props { groupId: number; - addCollaborator: AddCollaboratorForm; users: PaginatedData | undefined; usersLoading: boolean; usersError: Error | null; + onSuccess?: () => void; } - let { groupId, addCollaborator, users, usersLoading, usersError }: Props = $props(); + let { groupId, 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: groupId, userId: 0, permission: Permission.Edit }, + valibot(AddCollaboratorSchema) + ), + { + id: `add-group-collab-${groupId}`, + validators: valibot(AddCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.addCollaborator(ResourceType.Groups, form.data.resourceId, { + userId: form.data.userId, + permission: form.data.permission as Permission.Edit | Permission.Manage + }); + toast.success(m.group_collaborators_add_success()); + dialogOpen = false; + resetForm(); + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Add group collaborator error:', error); + toast.error(m.group_collaborators_add_error()); + } + } + } + ); + function resetForm() { searchQuery = ''; selectedUserId = null; @@ -107,28 +142,7 @@ {:else if usersLoading} {:else} - { - try { - await submit(); - toast.success(m.group_collaborators_add_success()); - dialogOpen = false; - resetForm(); - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.group_collaborators_add_error()); - } - } - })} - class="space-y-6" - > - - - - - + {m.group_collaborators_add_user_label()} @@ -157,7 +171,10 @@ {#each filteredUsers as user (user.id)} (selectedUserId = user.id)} + onclick={() => { + selectedUserId = user.id; + $form.userId = user.id; + }} class="flex w-full items-center gap-3 border-b border-border p-3 text-left transition-colors last:border-b-0 hover:bg-muted/50 {selectedUserId === user.id ? 'bg-primary/10' @@ -200,7 +217,10 @@ {m.group_collaborators_add_permission_label()} (selectedPermission = value as Permission)} + onValueChange={(value) => { + selectedPermission = value as Permission; + $form.permission = value as Permission; + }} > {selectedPermission @@ -226,15 +246,16 @@ dialogOpen = false; resetForm(); }} + disabled={$submitting} > {m.group_collaborators_add_cancel()} - {m.group_collaborators_add_submit()} + {$submitting ? 'Adding...' : m.group_collaborators_add_submit()} diff --git a/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte b/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte index 2786998..1fd0c49 100644 --- a/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte +++ b/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte @@ -4,40 +4,48 @@ import { Label } from '$lib/components/ui/label'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Dialog from '$lib/components/ui/dialog'; + import { LoadingSpinner, ErrorCard } from '$lib/components/common'; import UserPlus from '@lucide/svelte/icons/user-plus'; import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages'; import { SvelteSet } from 'svelte/reactivity'; - import { - addUsersToGroup, - getAllUsers, - getGroupMembers - } from '$routes/dashboard/teacher/groups/[groupId]/group.remote'; + import { createParameterizedQuery } from '$lib/utils/query.svelte'; + import { getAccessControlInstance, getGroupsManagementInstance } from '$lib/services'; + import { ResourceType } from '$lib/dto/accessControl'; + import type { User } from '$lib/dto/user'; interface Props { groupId: number; + onSuccess?: () => void; } - let { groupId }: Props = $props(); + let { groupId, onSuccess }: Props = $props(); let dialogOpen = $state(false); let searchQuery = $state(''); let selectedUserIds = $state(new SvelteSet()); + let submitting = $state(false); + + const groupsService = getGroupsManagementInstance(); + const accessControlService = getAccessControlInstance(); + + const assignableUsersQuery = createParameterizedQuery( + () => groupId, + async (id) => { + if (!accessControlService) throw new Error('Access control service unavailable'); + const result = await accessControlService.getAssignableUsers(ResourceType.Groups, id); + if (!result.success) throw new Error(result.error || 'Failed to fetch assignable users'); + return result.data!; + } + ); - const usersQuery = getAllUsers(); - const membersQuery = getGroupMembers(groupId); - - let availableUsers = $derived.by(() => { - if (!usersQuery.current?.items || !membersQuery.current) return []; - const memberIds = new SvelteSet(membersQuery.current.map((m) => m.id)); - return usersQuery.current.items.filter((user) => !memberIds.has(user.id)); - }); - + // Filter by search query let filteredUsers = $derived.by(() => { - if (!searchQuery.trim()) return availableUsers; + const users = assignableUsersQuery.current?.items ?? []; + if (!searchQuery.trim()) return users; const query = searchQuery.toLowerCase(); - return availableUsers.filter( - (user) => + return users.filter( + (user: User) => user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query) || user.name.toLowerCase().includes(query) || @@ -56,19 +64,27 @@ } async function handleSubmit() { - if (selectedUserIds.size === 0) { + if (!groupsService || selectedUserIds.size === 0) { toast.error(m.group_members_add_error()); return; } + submitting = true; try { - await addUsersToGroup({ groupId, userIDs: Array.from(selectedUserIds) }); + await groupsService.addUsersToGroup(groupId, Array.from(selectedUserIds)); toast.success(m.group_members_add_success()); dialogOpen = false; selectedUserIds = new SvelteSet(); searchQuery = ''; - } catch { + + await assignableUsersQuery.refresh(); + + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Add users error:', error); toast.error(m.group_members_add_error()); + } finally { + submitting = false; } } @@ -81,10 +97,10 @@ !open && resetForm()}> (dialogOpen = true)} - class="group relative overflow-hidden rounded-2xl border border-border bg-linear-to-br from-primary to-secondary p-6 shadow-md transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" + class="group relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-primary to-secondary p-6 shadow-md transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" > @@ -133,10 +149,16 @@ - {#if usersQuery.error} - {m.group_members_users_load_error()} - {:else if usersQuery.loading} - {m.groups_loading()} + {#if assignableUsersQuery.error} + { + assignableUsersQuery.refresh(); + }} + /> + {:else if assignableUsersQuery.loading} + {:else if filteredUsers.length === 0} {m.group_members_no_users_found()} {:else} @@ -165,11 +187,20 @@ - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={submitting} + > {m.group_members_add_cancel()} - - {m.group_members_add_submit()} + + {submitting ? 'Adding...' : m.group_members_add_submit()} diff --git a/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte b/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte index 938c6c3..87e3e3d 100644 --- a/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte +++ b/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte @@ -5,17 +5,45 @@ import * as Dialog from '$lib/components/ui/dialog'; import Plus from '@lucide/svelte/icons/plus'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; - import type { CreateGroupForm } from '$routes/dashboard/teacher/groups/groups.remote'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { CreateGroupSchema } from '$lib/schemas'; + import { getGroupsManagementInstance } from '$lib/services'; interface Props { - createGroup: CreateGroupForm; + onSuccess?: () => void; } - let { createGroup }: Props = $props(); + let { onSuccess }: Props = $props(); + + const groupsService = getGroupsManagementInstance(); let dialogOpen = $state(false); + + // Initialize superform for SPA mode with client-side validation + const { form, errors, enhance, submitting } = superForm(defaults(valibot(CreateGroupSchema)), { + id: 'create-group', + validators: valibot(CreateGroupSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!groupsService || !form.valid) return; + + try { + await groupsService.createGroup({ + name: form.data.name + }); + toast.success(m.groups_create_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Create group error:', error); + toast.error(m.groups_create_error()); + } + } + }); @@ -52,44 +80,35 @@ - { - try { - await submit(); - toast.success(m.groups_create_success()); - dialogOpen = false; - } catch (error) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.groups_create_error()); - } - } - })} - class="space-y-6" - > + {m.groups_form_name_label()} - {#each createGroup.fields.name.issues() as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.name} + {$errors.name} + {/if} - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={$submitting} + > {m.groups_form_cancel()} - - {m.groups_form_create()} + + {$submitting ? 'Creating...' : m.groups_form_create()} diff --git a/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte b/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte index f815aea..fc2505f 100644 --- a/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte +++ b/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte @@ -4,18 +4,55 @@ import { Label } from '$lib/components/ui/label'; import * as Dialog from '$lib/components/ui/dialog'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; import type { Group } from '$lib/dto/group'; - import type { UpdateGroupForm } from '$routes/dashboard/teacher/groups/[groupId]/group.remote'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { UpdateGroupSchema } from '$lib/schemas'; + import { getGroupsManagementInstance } from '$lib/services'; interface Props { group: Group; dialogOpen: boolean; - updateGroup: UpdateGroupForm; + onSuccess?: () => void; } - let { group, dialogOpen = $bindable(), updateGroup }: Props = $props(); + let { group, dialogOpen = $bindable(), onSuccess }: Props = $props(); + + const groupsService = getGroupsManagementInstance(); + + // Initialize superform for SPA mode with client-side validation + const { form, errors, enhance, submitting } = superForm( + defaults( + { + groupId: group.id, + name: group.name + }, + valibot(UpdateGroupSchema) + ), + { + id: `edit-group-${group.id}`, + validators: valibot(UpdateGroupSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!groupsService || !form.valid) return; + + try { + await groupsService.updateGroup(form.data.groupId, { + name: form.data.name + }); + toast.success(m.groups_edit_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Update group error:', error); + toast.error(m.groups_edit_error()); + } + } + } + ); @@ -27,47 +64,35 @@ - { - try { - await submit(); - toast.success(m.groups_edit_success()); - dialogOpen = false; - } catch (error) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.groups_edit_error()); - } - } - })} - class="space-y-6" - > - - + {m.groups_form_name_label()} - {#each updateGroup.fields.name.issues() as issue (issue.message)} - {issue.message} - {/each} + {#if $errors.name} + {$errors.name} + {/if} - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={$submitting} + > {m.groups_form_cancel()} - - {m.groups_form_update()} + + {$submitting ? 'Updating...' : m.groups_form_update()} diff --git a/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte b/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte index e5a251a..3ffc588 100644 --- a/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte +++ b/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.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/groups/[groupId]/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 { groupId: 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: groupId, userId, permission: currentPermission }, + valibot(UpdateCollaboratorSchema) + ), + { + id: `group-collab-perm-${groupId}-${userId}`, + validators: valibot(UpdateCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.updateCollaborator( + ResourceType.Groups, + form.data.resourceId, + form.data.userId, + { permission: form.data.permission as Permission.Edit | Permission.Manage } + ); + toast.success(m.group_collaborators_update_success()); + dialogOpen = false; + selectedPermission = null; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Update group collaborator error:', error); + toast.error(m.group_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.group_collaborators_update_success()); - dialogOpen = false; - selectedPermission = null; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.group_collaborators_update_error()); - } - } finally { - isUpdating = false; - } - })} - > - - - - + - + {m.group_collaborators_update_cancel()} - - {m.group_collaborators_update_confirm()} + + {$submitting ? 'Updating...' : m.group_collaborators_update_confirm()} diff --git a/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte b/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte index 0c6ea9a..f93bcbb 100644 --- a/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte @@ -3,10 +3,9 @@ import { Button } from '$lib/components/ui/button'; import X from '@lucide/svelte/icons/x'; 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 { RemoveCollaboratorForm } from '$routes/dashboard/teacher/groups/[groupId]/collaborators/collaborators.remote'; + import { Permission, ResourceType } from '$lib/dto/accessControl'; + import { getAccessControlInstance } from '$lib/services'; interface Props { groupId: number; @@ -14,17 +13,13 @@ userName: string; targetPermission: Permission; currentUserPermission: Permission; - removeCollaborator: RemoveCollaboratorForm; + onSuccess?: () => void; } - let { - groupId, - userId, - userName, - targetPermission, - currentUserPermission, - removeCollaborator - }: Props = $props(); + let { groupId, userId, userName, targetPermission, currentUserPermission, onSuccess }: Props = + $props(); + + const accessControlService = getAccessControlInstance(); let dialogOpen = $state(false); let isRemoving = $state(false); @@ -55,6 +50,28 @@ function handleCancel() { dialogOpen = false; } + + async function handleRemove(event: Event) { + event.preventDefault(); + + if (!accessControlService) { + toast.error(m.group_collaborators_remove_error()); + return; + } + + isRemoving = true; + try { + await accessControlService.deleteCollaborator(ResourceType.Groups, groupId, userId); + toast.success(m.group_collaborators_remove_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove group collaborator error:', error); + toast.error(m.group_collaborators_remove_error()); + } finally { + isRemoving = false; + } + } {#if canRemove} @@ -77,33 +94,13 @@ - { - isRemoving = true; - try { - await submit(); - toast.success(m.group_collaborators_remove_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.group_collaborators_remove_error()); - } - } finally { - isRemoving = false; - } - })} - > - - - + {m.group_collaborators_remove_cancel()} - {m.group_collaborators_remove_confirm()} + {isRemoving ? 'Removing...' : m.group_collaborators_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte b/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte index da31d13..42a9a0e 100644 --- a/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte +++ b/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte @@ -4,25 +4,39 @@ import X from '@lucide/svelte/icons/x'; import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages'; - import { removeUsersFromGroup } from '$routes/dashboard/teacher/groups/[groupId]/group.remote'; + import { getGroupsManagementInstance } from '$lib/services'; interface Props { groupId: number; userId: number; userName: string; + onSuccess?: () => void; } - let { groupId, userId, userName }: Props = $props(); + let { groupId, userId, userName, onSuccess }: Props = $props(); let dialogOpen = $state(false); + let submitting = $state(false); + + const groupsService = getGroupsManagementInstance(); async function handleRemove() { + if (!groupsService) { + toast.error(m.group_members_remove_error()); + return; + } + + submitting = true; try { - await removeUsersFromGroup({ groupId, userIDs: [userId] }); + await groupsService.removeUsersFromGroup(groupId, [userId]); toast.success(m.group_members_remove_success()); dialogOpen = false; - } catch { + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove user error:', error); toast.error(m.group_members_remove_error()); + } finally { + submitting = false; } } @@ -41,11 +55,16 @@ - (dialogOpen = false)}> + (dialogOpen = false)} + disabled={submitting} + > {m.group_members_remove_cancel()} - - {m.group_members_remove_confirm()} + + {submitting ? 'Removing...' : m.group_members_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/tasks/AddCollaboratorButton.svelte b/src/lib/components/dashboard/admin/tasks/AddCollaboratorButton.svelte index 6aa88a2..968e525 100644 --- a/src/lib/components/dashboard/admin/tasks/AddCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/tasks/AddCollaboratorButton.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/tasks/[taskId]/collaborators/collaborators.remote'; + import { getAccessControlInstance } from '$lib/services'; import { LoadingSpinner } from '$lib/components/common'; import type { PaginatedData } from '$lib/dto/response'; + import { AddCollaboratorSchema } from '$lib/schemas'; + import { defaults, superForm } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; interface Props { taskId: number; - addCollaborator: AddCollaboratorForm; users: PaginatedData | undefined; usersLoading: boolean; usersError: Error | null; + onSuccess?: () => void; } - let { taskId, addCollaborator, users, usersLoading, usersError }: Props = $props(); + let { taskId, 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: taskId, userId: 0, permission: Permission.Edit }, + valibot(AddCollaboratorSchema) + ), + { + id: `add-task-collab-${taskId}`, + validators: valibot(AddCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.addCollaborator(ResourceType.Tasks, form.data.resourceId, { + userId: form.data.userId, + permission: form.data.permission as Permission.Edit | Permission.Manage + }); + toast.success(m.task_collaborators_add_success()); + dialogOpen = false; + resetForm(); + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Add task collaborator error:', error); + toast.error(m.task_collaborators_add_error()); + } + } + } + ); + function resetForm() { searchQuery = ''; selectedUserId = null; @@ -107,28 +142,7 @@ {:else if usersLoading} {:else} - { - try { - await submit(); - toast.success(m.task_collaborators_add_success()); - dialogOpen = false; - resetForm(); - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.task_collaborators_add_error()); - } - } - })} - class="space-y-6" - > - - - - - + {m.task_collaborators_add_user_label()} @@ -157,7 +171,10 @@ {#each filteredUsers as user (user.id)} (selectedUserId = user.id)} + onclick={() => { + selectedUserId = user.id; + $form.userId = user.id; + }} class="flex w-full items-center gap-3 border-b border-border p-3 text-left transition-colors last:border-b-0 hover:bg-muted/50 {selectedUserId === user.id ? 'bg-primary/10' @@ -200,7 +217,10 @@ {m.task_collaborators_add_permission_label()} (selectedPermission = value as Permission)} + onValueChange={(value) => { + selectedPermission = value as Permission; + $form.permission = value as Permission; + }} > {selectedPermission @@ -226,15 +246,16 @@ dialogOpen = false; resetForm(); }} + disabled={$submitting} > {m.task_collaborators_add_cancel()} - {m.task_collaborators_add_submit()} + {$submitting ? 'Adding...' : m.task_collaborators_add_submit()} diff --git a/src/lib/components/dashboard/admin/tasks/CollaboratorPermissionEditor.svelte b/src/lib/components/dashboard/admin/tasks/CollaboratorPermissionEditor.svelte index af2dde3..daedce0 100644 --- a/src/lib/components/dashboard/admin/tasks/CollaboratorPermissionEditor.svelte +++ b/src/lib/components/dashboard/admin/tasks/CollaboratorPermissionEditor.svelte @@ -6,37 +6,66 @@ 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/tasks/[taskId]/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 { taskId: number; userId: number; userName: string; currentPermission: Permission; - updateCollaborator: UpdateCollaboratorForm; canEdit?: boolean; + onSuccess?: () => void; } - let { - taskId, - userId, - userName, - currentPermission, - updateCollaborator, - canEdit = false - }: Props = $props(); + let { taskId, userId, userName, currentPermission, 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: taskId, userId, permission: currentPermission }, + valibot(UpdateCollaboratorSchema) + ), + { + id: `task-collab-perm-${taskId}-${userId}`, + validators: valibot(UpdateCollaboratorSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!accessControlService || !form.valid) return; + + try { + await accessControlService.updateCollaborator( + ResourceType.Tasks, + form.data.resourceId, + form.data.userId, + { permission: form.data.permission as Permission.Edit | Permission.Manage } + ); + toast.success(m.task_collaborators_update_success()); + dialogOpen = false; + selectedPermission = null; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Update task collaborator error:', error); + toast.error(m.task_collaborators_update_error()); + } + } + } + ); + function getPermissionLabel(permission: Permission): string { switch (permission) { case Permission.Edit: @@ -72,6 +101,7 @@ // Different permission selected, show confirmation dialog selectedPermission = permission; + $form.permission = permission; popoverOpen = false; dialogOpen = true; } @@ -151,35 +181,13 @@ - { - isUpdating = true; - try { - await submit(); - toast.success(m.task_collaborators_update_success()); - dialogOpen = false; - selectedPermission = null; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.task_collaborators_update_error()); - } - } finally { - isUpdating = false; - } - })} - > - - - - + - + {m.task_collaborators_update_cancel()} - - {m.task_collaborators_update_confirm()} + + {$submitting ? 'Updating...' : m.task_collaborators_update_confirm()} diff --git a/src/lib/components/dashboard/admin/tasks/ManageTestCasesLimitsDialog.svelte b/src/lib/components/dashboard/admin/tasks/ManageTestCasesLimitsDialog.svelte index bc8bfa6..13110c2 100644 --- a/src/lib/components/dashboard/admin/tasks/ManageTestCasesLimitsDialog.svelte +++ b/src/lib/components/dashboard/admin/tasks/ManageTestCasesLimitsDialog.svelte @@ -1,8 +1,5 @@ @@ -57,38 +98,7 @@ {m.admin_tasks_no_test_cases()} {:else} - { - try { - await submit(); - toast.success(m.admin_tasks_test_cases_updated()); - open = false; - } catch (error) { - toast.error(m.admin_tasks_test_cases_update_error()); - } - })} - > - - - {#each editedLimits as limit, i (limit.order)} - - - - {/each} - + {m.admin_tasks_test_case_id()} {m.admin_tasks_memory_limit()} @@ -134,17 +144,15 @@ type="button" variant="outline" onclick={() => (open = false)} - disabled={!!updateTaskLimits.pending} + disabled={submitting} > {m.admin_tasks_form_cancel()} - {#if updateTaskLimits.pending} + {#if submitting} {/if} {m.admin_tasks_save_changes()} diff --git a/src/lib/components/dashboard/admin/tasks/RemoveCollaboratorButton.svelte b/src/lib/components/dashboard/admin/tasks/RemoveCollaboratorButton.svelte index f089f60..a774c88 100644 --- a/src/lib/components/dashboard/admin/tasks/RemoveCollaboratorButton.svelte +++ b/src/lib/components/dashboard/admin/tasks/RemoveCollaboratorButton.svelte @@ -3,10 +3,9 @@ import { Button } from '$lib/components/ui/button'; import X from '@lucide/svelte/icons/x'; 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 { RemoveCollaboratorForm } from '$routes/dashboard/teacher/tasks/[taskId]/collaborators/collaborators.remote'; + import { Permission, ResourceType } from '$lib/dto/accessControl'; + import { getAccessControlInstance } from '$lib/services'; interface Props { taskId: number; @@ -14,17 +13,13 @@ userName: string; targetPermission: Permission; currentUserPermission: Permission; - removeCollaborator: RemoveCollaboratorForm; + onSuccess?: () => void; } - let { - taskId, - userId, - userName, - targetPermission, - currentUserPermission, - removeCollaborator - }: Props = $props(); + let { taskId, userId, userName, targetPermission, currentUserPermission, onSuccess }: Props = + $props(); + + const accessControlService = getAccessControlInstance(); let dialogOpen = $state(false); let isRemoving = $state(false); @@ -55,6 +50,28 @@ function handleCancel() { dialogOpen = false; } + + async function handleRemove(event: Event) { + event.preventDefault(); + + if (!accessControlService) { + toast.error(m.task_collaborators_remove_error()); + return; + } + + isRemoving = true; + try { + await accessControlService.deleteCollaborator(ResourceType.Tasks, taskId, userId); + toast.success(m.task_collaborators_remove_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Remove task collaborator error:', error); + toast.error(m.task_collaborators_remove_error()); + } finally { + isRemoving = false; + } + } {#if canRemove} @@ -77,33 +94,13 @@ - { - isRemoving = true; - try { - await submit(); - toast.success(m.task_collaborators_remove_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.task_collaborators_remove_error()); - } - } finally { - isRemoving = false; - } - })} - > - - - + {m.task_collaborators_remove_cancel()} - {m.task_collaborators_remove_confirm()} + {isRemoving ? 'Removing...' : m.task_collaborators_remove_confirm()} diff --git a/src/lib/components/dashboard/admin/tasks/RemoveTaskButton.svelte b/src/lib/components/dashboard/admin/tasks/RemoveTaskButton.svelte index ef4118b..4df95cf 100644 --- a/src/lib/components/dashboard/admin/tasks/RemoveTaskButton.svelte +++ b/src/lib/components/dashboard/admin/tasks/RemoveTaskButton.svelte @@ -3,24 +3,47 @@ import { Button } from '$lib/components/ui/button'; import Trash2 from '@lucide/svelte/icons/trash-2'; import { toast } from 'svelte-sonner'; - import { isHttpError } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages'; - import type { DeleteTaskForm } from '$routes/dashboard/teacher/tasks/tasks.remote'; + import { getTasksManagementInstance } from '$lib/services'; interface Props { taskId: number; taskTitle: string; - deleteTask: DeleteTaskForm; + onSuccess?: () => void; } - let { taskId, taskTitle, deleteTask }: Props = $props(); + let { taskId, taskTitle, onSuccess }: Props = $props(); let dialogOpen = $state(false); let isDeleting = $state(false); + const tasksManagementService = getTasksManagementInstance(); + function handleCancel() { dialogOpen = false; } + + async function handleDelete(event: Event) { + event.preventDefault(); + + if (!tasksManagementService) { + toast.error(m.admin_tasks_remove_error()); + return; + } + + isDeleting = true; + try { + await tasksManagementService.deleteTask(taskId); + toast.success(m.admin_tasks_remove_success()); + dialogOpen = false; + if (onSuccess) onSuccess(); + } catch (error) { + console.error('Delete task error:', error); + toast.error(m.admin_tasks_remove_error()); + } finally { + isDeleting = false; + } + } - { - isDeleting = true; - try { - await submit(); - toast.success(m.admin_tasks_remove_success()); - dialogOpen = false; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.admin_tasks_remove_error()); - } - } finally { - isDeleting = false; - } - })} - > - - + {m.admin_tasks_remove_cancel_button()} diff --git a/src/lib/components/dashboard/admin/tasks/TaskVisibilityToggle.svelte b/src/lib/components/dashboard/admin/tasks/TaskVisibilityToggle.svelte index 275fa57..3414597 100644 --- a/src/lib/components/dashboard/admin/tasks/TaskVisibilityToggle.svelte +++ b/src/lib/components/dashboard/admin/tasks/TaskVisibilityToggle.svelte @@ -1,11 +1,11 @@ {#each tasks as task (task.id)} - + {/each} diff --git a/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte b/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte index 4a2ffdb..f3f0c0b 100644 --- a/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte +++ b/src/lib/components/dashboard/admin/tasks/TasksUploadDialog.svelte @@ -1,13 +1,16 @@ @@ -57,98 +67,82 @@ {m.admin_tasks_dialog_title()} {m.admin_tasks_dialog_description()} - {#if uploadLimit.loading} - ({m.admin_tasks_upload_limit_loading()}) - {:else if uploadLimit.error} - ({m.admin_tasks_upload_limit_unavailable()}) - {:else if uploadLimit.current} - ({m.admin_tasks_upload_limit({ limit: MAX_UPLOAD_MB })}) - {/if} + ({m.admin_tasks_upload_limit({ limit: MAX_UPLOAD_MB })}) - { - try { - await submit(); - await handleTaskUploadSuccess(); - } catch (error) { - if (isHttpError(error)) { - toast.error(error?.body?.message || m.admin_tasks_upload_error()); - } else { - toast.error(m.admin_tasks_upload_error()); - } - } - })} - class="space-y-6" - > + {m.admin_tasks_form_title_label()} + {#if $errors.title} + {$errors.title} + {/if} - {m.admin_tasks_form_archive_label()} + {m.admin_tasks_form_file_label()} { + const input = e.currentTarget; + if (input.files && input.files.length > 0) { + $form.archive = input.files[0]; + } + }} /> - {#if selectedFile} + {#if $errors.archive} + {$errors.archive} + {/if} + {#if $form.archive} - {m.admin_tasks_form_selected_file({ - filename: selectedFile.name, - size: (selectedFile.size / 1024 / 1024).toFixed(2) + {m.task_file_selected({ + filename: $form.archive.name, + size: ($form.archive.size / 1024).toFixed(2) })} {/if} - - - - - {m.admin_tasks_form_visible_label()} - - - {m.admin_tasks_form_visible_description()} - - + + ($form.isVisible = checked === true)} + disabled={$submitting} + /> + + {m.admin_tasks_card_visible_to_users()} + - + {m.admin_tasks_form_cancel()} - - {m.admin_tasks_form_upload()} + + {#if $submitting} + + {/if} + {$submitting ? m.admin_tasks_upload_uploading() : m.admin_tasks_upload_button()} diff --git a/src/lib/components/dashboard/admin/users/UserEditDialog.svelte b/src/lib/components/dashboard/admin/users/UserEditDialog.svelte index 2eb061e..8b78965 100644 --- a/src/lib/components/dashboard/admin/users/UserEditDialog.svelte +++ b/src/lib/components/dashboard/admin/users/UserEditDialog.svelte @@ -1,4 +1,10 @@ @@ -59,88 +97,84 @@ {#if user} - { - try { - await submit(); - await handleSuccess(); - } catch (error) { - if (isHttpError(error)) { - toast.error(error.body.message || m.admin_users_edit_error()); - } else { - toast.error(m.admin_users_edit_error()); - } - } - })} - class="space-y-4" - > - - + {m.register_name_label()} + {#if $errors.name} + {$errors.name} + {/if} {m.register_surname_label()} + {#if $errors.surname} + {$errors.surname} + {/if} {m.register_username_label()} + {#if $errors.username} + {$errors.username} + {/if} {m.register_email_label()} + {#if $errors.email} + {$errors.email} + {/if} {m.admin_registration_requests_role()} { if (value) { - selectedRoleValue = value; + $form.role = value as UserRole; } }} + disabled={$submitting} > {selectedRoleLabel} @@ -153,13 +187,18 @@ {/each} + {#if $errors.role} + {$errors.role} + {/if} - {m.admin_contests_form_cancel()} - {m.admin_tasks_save_changes()} + + {m.admin_contests_form_cancel()} + + + {$submitting ? 'Saving...' : m.admin_tasks_save_changes()} + {/if} diff --git a/src/lib/components/dashboard/contests/AdminContestCard.svelte b/src/lib/components/dashboard/contests/AdminContestCard.svelte index 88476a9..76164ce 100644 --- a/src/lib/components/dashboard/contests/AdminContestCard.svelte +++ b/src/lib/components/dashboard/contests/AdminContestCard.svelte @@ -20,9 +20,10 @@ interface AdminContestCardProps { contest: CreatedContest; + onContestUpdated?: () => void; } - let { contest }: AdminContestCardProps = $props(); + let { contest, onContestUpdated }: AdminContestCardProps = $props(); let editDialogOpen = $state(false); @@ -191,4 +192,4 @@ - + diff --git a/src/lib/components/dashboard/profile/ChangePasswordDialog.svelte b/src/lib/components/dashboard/profile/ChangePasswordDialog.svelte index b54e924..72ee475 100644 --- a/src/lib/components/dashboard/profile/ChangePasswordDialog.svelte +++ b/src/lib/components/dashboard/profile/ChangePasswordDialog.svelte @@ -3,8 +3,11 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import { changePassword } from '../../../../routes/dashboard/user/profile/password.remote'; import { toast } from 'svelte-sonner'; + import { getUserInstance } from '$lib/services'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { ChangePasswordSchema } from '$lib/schemas'; import Lock from '@lucide/svelte/icons/lock'; import Eye from '@lucide/svelte/icons/eye'; import EyeOff from '@lucide/svelte/icons/eye-off'; @@ -17,6 +20,36 @@ let { open = $bindable(), onOpenChange }: ChangePasswordDialogProps = $props(); + const userService = getUserInstance(); + + const { form, errors, enhance, submitting, reset } = superForm( + defaults(valibot(ChangePasswordSchema)), + { + id: 'change-password', + validators: valibot(ChangePasswordSchema), + SPA: true, + resetForm: true, + async onUpdate({ form }) { + if (!userService || !form.valid) return; + + // Get current user first to get their ID + const userResult = await userService.getCurrentUser(); + if (!userResult.success || !userResult.data) { + toast.error(userResult.error || 'Failed to get current user'); + return; + } + + const result = await userService.changePassword(userResult.data.id, form.data); + if (result.success) { + toast.success(m.profile_password_change_success()); + handleClose(); + } else { + toast.error(result.error || m.profile_password_change_error()); + } + } + } + ); + let showCurrentPassword = $state(false); let showNewPassword = $state(false); let showConfirmPassword = $state(false); @@ -24,35 +57,13 @@ function handleClose() { open = false; onOpenChange(false); - // Reset form when closing - changePassword.fields.set({ - oldPassword: '', - newPassword: '', - newPasswordConfirm: '' - }); + reset(); } // Helper function to get the first error for a field - function getFirstError(issues: unknown) { - if (!issues || !Array.isArray(issues) || issues.length === 0) return null; - return issues[0] || null; - } - - // Helper function to get first general form error (excluding password mismatch) - function getFirstGeneralError() { - const allIssues = changePassword.fields.allIssues(); - if (!allIssues || !Array.isArray(allIssues) || allIssues.length === 0) return null; - - // Filter out password mismatch error to avoid duplication (it's already shown on confirm field) - const generalErrors = allIssues.filter( - (issue) => - issue && - typeof issue === 'object' && - 'message' in issue && - issue.message !== m.validation_passwords_match() - ); - - return generalErrors.length > 0 ? generalErrors[0] : null; + function getFirstError(fieldErrors: string[] | undefined) { + if (!fieldErrors || fieldErrors.length === 0) return null; + return fieldErrors[0]; } @@ -70,29 +81,14 @@ - { - try { - await submit(); - // Check if form was actually successful by looking at the result - if (changePassword.result?.success) { - toast.success(m.profile_password_change_success()); - handleClose(); - } - // If there are validation errors or no success, keep dialog open - } catch { - toast.error(m.profile_password_change_error()); - } - })} - class="space-y-4" - oninput={() => changePassword.validate()} - > + {m.profile_current_password_label()} - {#if getFirstError(changePassword.fields.oldPassword.issues())} + {#if getFirstError($errors.oldPassword)} - {getFirstError(changePassword.fields.oldPassword.issues())?.message} + {getFirstError($errors.oldPassword)} {/if} @@ -124,7 +120,8 @@ {m.profile_new_password_label()} - {#if getFirstError(changePassword.fields.newPassword.issues())} + {#if getFirstError($errors.newPassword)} - {getFirstError(changePassword.fields.newPassword.issues())?.message} + {getFirstError($errors.newPassword)} {/if} @@ -156,7 +153,8 @@ {m.profile_confirm_password_label()} - {#if getFirstError(changePassword.fields.newPasswordConfirm.issues())} + {#if getFirstError($errors.newPasswordConfirm)} - {getFirstError(changePassword.fields.newPasswordConfirm.issues())?.message} + {getFirstError($errors.newPasswordConfirm)} {/if} @@ -188,8 +186,8 @@ {m.profile_password_change_cancel()} - - {#if changePassword.pending} + + {#if $submitting} {m.profile_password_changing()} {:else} {m.profile_password_change_submit()} diff --git a/src/lib/components/dashboard/tasks/AdminTaskCard.svelte b/src/lib/components/dashboard/tasks/AdminTaskCard.svelte index d76d353..54889de 100644 --- a/src/lib/components/dashboard/tasks/AdminTaskCard.svelte +++ b/src/lib/components/dashboard/tasks/AdminTaskCard.svelte @@ -14,14 +14,13 @@ import ManageTestCasesLimitsDialog from '$lib/components/dashboard/admin/tasks/ManageTestCasesLimitsDialog.svelte'; import TaskVisibilityToggle from '$lib/components/dashboard/admin/tasks/TaskVisibilityToggle.svelte'; import RemoveTaskButton from '$lib/components/dashboard/admin/tasks/RemoveTaskButton.svelte'; - import type { DeleteTaskForm } from '$routes/dashboard/teacher/tasks/tasks.remote'; interface AdminTaskCardProps { task: Task; - deleteTask: DeleteTaskForm; + onTaskDeleted?: () => void; } - let { task, deleteTask }: AdminTaskCardProps = $props(); + let { task, onTaskDeleted }: AdminTaskCardProps = $props(); let manageDialogOpen = $state(false); @@ -41,7 +40,7 @@ > {m.admin_tasks_card_id_prefix()}{task.id} - + + import * as Card from '$lib/components/ui/card'; + import { Button } from '$lib/components/ui/button'; + import FileText from '@lucide/svelte/icons/file-text'; + import Calendar from '@lucide/svelte/icons/calendar'; + import type { Task } from '$lib/dto/task'; + import { formatDate } from '$lib/utils'; + import { localizeHref } from '$lib/paraglide/runtime'; + import { AppRoutes } from '$lib/routes'; + import * as m from '$lib/paraglide/messages'; + + interface BasicTaskCardProps { + task: Task; + } + + let { task }: BasicTaskCardProps = $props(); + + + + + + + + + #{task.id} + + + + + {task.title} + + + + + + + + {m.admin_tasks_card_created()} + {formatDate(task.createdAt)} + + + + + {m.admin_tasks_card_view_details()} + + + diff --git a/src/lib/components/dashboard/tasks/task-page/tasks/ContestTaskSubmissionForm.svelte b/src/lib/components/dashboard/tasks/task-page/tasks/ContestTaskSubmissionForm.svelte index 68ee315..d48c55a 100644 --- a/src/lib/components/dashboard/tasks/task-page/tasks/ContestTaskSubmissionForm.svelte +++ b/src/lib/components/dashboard/tasks/task-page/tasks/ContestTaskSubmissionForm.svelte @@ -7,34 +7,71 @@ import FileUploader from './FileUploader.svelte'; import SubmitButton from './SubmitButton.svelte'; import { toast } from 'svelte-sonner'; - import { isHttpError, type HttpError } from '@sveltejs/kit'; - import type { SubmitContestSolutionRemoteForm } from '$routes/dashboard/user/contests/[contestId]/tasks/[taskId]/submit.remote'; + import { getSubmissionInstance } from '$lib/services'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { SubmitContestSolutionSchema } from '$lib/schemas'; import type { Language } from '$lib/dto/submission'; interface Props { languages: Language[]; loading?: boolean; - error?: HttpError | Error | null; - submitAction: SubmitContestSolutionRemoteForm; + error?: any; contestId: number; taskId: number; fileContent: string; + onSuccess?: () => void; } let { languages, loading = false, error = null, - submitAction, contestId, taskId, - fileContent = $bindable() + fileContent = $bindable(), + onSuccess }: Props = $props(); - let selectedLanguageId = $state(null); + const submissionService = getSubmissionInstance(); + + const { form, errors, enhance, submitting } = superForm( + defaults( + { contestId, taskId, languageId: 0, solution: new File([], '') }, + valibot(SubmitContestSolutionSchema) + ), + { + id: `submit-contest-task-${contestId}-${taskId}`, + validators: valibot(SubmitContestSolutionSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!submissionService || !form.valid) return; + + const result = await submissionService.submitSolution({ + taskID: form.data.taskId, + contestID: form.data.contestId, + solution: form.data.solution, + languageID: form.data.languageId + }); + + if (result.success) { + toast.success(m.task_submit_success()); + selectedFiles = null; + selectedLanguageId = null; + fileUploader?.clear(); + onSuccess?.(); + } else { + toast.error(result.error || m.task_submit_error()); + } + } + } + ); + let selectedFiles = $state(null); let fileUploader = $state(null); - let formElement = $state(null); + let selectedLanguageId = $state(null); function getFileExtension(filename: string): string { return filename.split('.').pop()?.toLowerCase() || ''; @@ -48,12 +85,19 @@ return fileExt === language.fileExtension.toLowerCase(); } - async function handleSubmit() { + async function handleSubmit(e: Event) { + e.preventDefault(); + if (!selectedFiles || !selectedLanguageId) { toast.error(m.task_submit_validation_both()); return; } + if (selectedFiles.length === 0) { + toast.error(m.task_submit_validation_both()); + return; + } + if (!validateFileExtension(selectedFiles[0], selectedLanguageId)) { const language = languages.find((l) => l.id === selectedLanguageId); toast.error( @@ -64,10 +108,9 @@ ); return; } - - formElement?.requestSubmit(); } + // Read file content for preview $effect(() => { if (selectedFiles && selectedFiles.length > 0) { const reader = new FileReader(); @@ -98,53 +141,37 @@ {:else} { - try { - await submit(); - toast.success(m.task_submit_success()); - selectedFiles = new DataTransfer().files; - selectedLanguageId = null; - fileUploader?.clear(); - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.task_submit_error()); - } - } - })} + method="POST" + class="space-y-4" + use:enhance + onsubmit={handleSubmit} > - - - - { + if (id !== null) { + $form.languageId = id; + } + }} /> - - - - + { + if (files && files.length > 0) { + $form.solution = files[0]; + } + }} + /> - + + {/if} diff --git a/src/lib/components/dashboard/tasks/task-page/tasks/FileUploader.svelte b/src/lib/components/dashboard/tasks/task-page/tasks/FileUploader.svelte index ab2f6b9..7934866 100644 --- a/src/lib/components/dashboard/tasks/task-page/tasks/FileUploader.svelte +++ b/src/lib/components/dashboard/tasks/task-page/tasks/FileUploader.svelte @@ -5,9 +5,10 @@ interface Props { selectedFiles: FileList | null; disabled?: boolean; + onChange?: (files: FileList | null) => void; } - let { selectedFiles = $bindable(), disabled = false }: Props = $props(); + let { selectedFiles = $bindable(), disabled = false, onChange }: Props = $props(); let fileInput = $state(null); @@ -26,6 +27,7 @@ onchange={(e) => { const target = e.target as HTMLInputElement; selectedFiles = target.files; + onChange?.(target.files); }} id="solution" name="solution" diff --git a/src/lib/components/dashboard/tasks/task-page/tasks/LanguageSelector.svelte b/src/lib/components/dashboard/tasks/task-page/tasks/LanguageSelector.svelte index a9d4924..8228f6c 100644 --- a/src/lib/components/dashboard/tasks/task-page/tasks/LanguageSelector.svelte +++ b/src/lib/components/dashboard/tasks/task-page/tasks/LanguageSelector.svelte @@ -13,9 +13,10 @@ interface Props { languages: Language[]; selectedLanguageId: number | null; + onChange?: (id: number | null) => void; } - let { languages, selectedLanguageId = $bindable() }: Props = $props(); + let { languages, selectedLanguageId = $bindable(), onChange }: Props = $props(); const selectedLanguage: Language | null = $derived.by(() => { if (!selectedLanguageId) { @@ -37,7 +38,11 @@ (selectedLanguageId = Number(value))} + onValueChange={(value) => { + const id = Number(value); + selectedLanguageId = id; + onChange?.(id); + }} > {selectedLanguageTriggerString} diff --git a/src/lib/components/dashboard/tasks/task-page/tasks/TaskSubmissionForm.svelte b/src/lib/components/dashboard/tasks/task-page/tasks/TaskSubmissionForm.svelte index 5e69cf9..e80eb48 100644 --- a/src/lib/components/dashboard/tasks/task-page/tasks/TaskSubmissionForm.svelte +++ b/src/lib/components/dashboard/tasks/task-page/tasks/TaskSubmissionForm.svelte @@ -7,8 +7,10 @@ import FileUploader from './FileUploader.svelte'; import SubmitButton from './SubmitButton.svelte'; import { toast } from 'svelte-sonner'; - import { isHttpError, type HttpError } from '@sveltejs/kit'; - import type { SubmitSolutionRemoteForm } from '$routes/dashboard/tasks/[taskId]/submit.remote'; + import { getSubmissionInstance } from '$lib/services'; + import { superForm, defaults } from 'sveltekit-superforms'; + import { valibot } from 'sveltekit-superforms/adapters'; + import { SubmitSolutionSchema } from '$lib/schemas'; interface Language { id: number; @@ -20,25 +22,56 @@ interface Props { languages: Language[]; loading?: boolean; - error?: HttpError | any; - submitAction: SubmitSolutionRemoteForm; + error?: any; taskId: number; fileContent: string; + onSuccess?: () => void; } let { languages, loading = false, error = null, - submitAction, taskId, - fileContent = $bindable() + fileContent = $bindable(), + onSuccess }: Props = $props(); - let selectedLanguageId = $state(null); + const submissionService = getSubmissionInstance(); + + const { form, errors, enhance, submitting } = superForm( + defaults({ taskId, languageId: 0, solution: new File([], '') }, valibot(SubmitSolutionSchema)), + { + id: `submit-task-${taskId}`, + validators: valibot(SubmitSolutionSchema), + SPA: true, + dataType: 'json', + resetForm: false, + async onUpdate({ form }) { + if (!submissionService || !form.valid) return; + + const result = await submissionService.submitSolution({ + taskID: form.data.taskId, + solution: form.data.solution, + languageID: form.data.languageId + }); + + if (result.success) { + toast.success(m.task_submit_success()); + selectedFiles = null; + selectedLanguageId = null; + fileUploader?.clear(); + onSuccess?.(); + } else { + toast.error(result.error || m.task_submit_error()); + } + } + } + ); + let selectedFiles = $state(null); let fileUploader = $state(null); - let formElement = $state(null); + let selectedLanguageId = $state(null); function getFileExtension(filename: string): string { return filename.split('.').pop()?.toLowerCase() || ''; @@ -52,12 +85,19 @@ return fileExt === language.fileExtension.toLowerCase(); } - async function handleSubmit() { + async function handleSubmit(e: Event) { + e.preventDefault(); + if (!selectedFiles || !selectedLanguageId) { toast.error(m.task_submit_validation_both()); return; } + if (selectedFiles.length === 0) { + toast.error(m.task_submit_validation_both()); + return; + } + if (!validateFileExtension(selectedFiles[0], selectedLanguageId)) { const language = languages.find((l) => l.id === selectedLanguageId); toast.error( @@ -68,10 +108,9 @@ ); return; } - - formElement?.requestSubmit(); } + // Read file content for preview $effect(() => { if (selectedFiles && selectedFiles.length > 0) { const reader = new FileReader(); @@ -102,52 +141,37 @@ {:else} { - try { - await submit(); - toast.success(m.task_submit_success()); - selectedFiles = new DataTransfer().files; - selectedLanguageId = null; - fileUploader?.clear(); - } catch (error: HttpError | unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.task_submit_error()); - } - } - })} + method="POST" + class="space-y-4" + use:enhance + onsubmit={handleSubmit} > - - - { + if (id !== null) { + $form.languageId = id; + } + }} /> - - - - + { + if (files && files.length > 0) { + $form.solution = files[0]; + } + }} + /> - + + {/if} diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte new file mode 100644 index 0000000..0133709 --- /dev/null +++ b/src/lib/components/ui/form/form-button.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/form/form-description.svelte b/src/lib/components/ui/form/form-description.svelte new file mode 100644 index 0000000..1897745 --- /dev/null +++ b/src/lib/components/ui/form/form-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/form/form-element-field.svelte b/src/lib/components/ui/form/form-element-field.svelte new file mode 100644 index 0000000..786688b --- /dev/null +++ b/src/lib/components/ui/form/form-element-field.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} + + {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} + + {/snippet} + diff --git a/src/lib/components/ui/form/form-field-errors.svelte b/src/lib/components/ui/form/form-field-errors.svelte new file mode 100644 index 0000000..aef1b08 --- /dev/null +++ b/src/lib/components/ui/form/form-field-errors.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children({ errors, errorProps })} + {#if childrenProp} + {@render childrenProp({ errors, errorProps })} + {:else} + {#each errors as error (error)} + {error} + {/each} + {/if} + {/snippet} + diff --git a/src/lib/components/ui/form/form-field.svelte b/src/lib/components/ui/form/form-field.svelte new file mode 100644 index 0000000..0272d67 --- /dev/null +++ b/src/lib/components/ui/form/form-field.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} + + {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} + + {/snippet} + diff --git a/src/lib/components/ui/form/form-fieldset.svelte b/src/lib/components/ui/form/form-fieldset.svelte new file mode 100644 index 0000000..03822c8 --- /dev/null +++ b/src/lib/components/ui/form/form-fieldset.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/lib/components/ui/form/form-label.svelte b/src/lib/components/ui/form/form-label.svelte new file mode 100644 index 0000000..540e220 --- /dev/null +++ b/src/lib/components/ui/form/form-label.svelte @@ -0,0 +1,24 @@ + + + + {#snippet child({ props })} + + {@render children?.()} + + {/snippet} + diff --git a/src/lib/components/ui/form/form-legend.svelte b/src/lib/components/ui/form/form-legend.svelte new file mode 100644 index 0000000..7890915 --- /dev/null +++ b/src/lib/components/ui/form/form-legend.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/form/index.ts b/src/lib/components/ui/form/index.ts new file mode 100644 index 0000000..f7ea4a7 --- /dev/null +++ b/src/lib/components/ui/form/index.ts @@ -0,0 +1,33 @@ +import * as FormPrimitive from 'formsnap'; +import Description from './form-description.svelte'; +import Label from './form-label.svelte'; +import FieldErrors from './form-field-errors.svelte'; +import Field from './form-field.svelte'; +import Fieldset from './form-fieldset.svelte'; +import Legend from './form-legend.svelte'; +import ElementField from './form-element-field.svelte'; +import Button from './form-button.svelte'; + +const Control = FormPrimitive.Control; + +export { + Field, + Control, + Label, + Button, + FieldErrors, + Description, + Fieldset, + Legend, + ElementField, + // + Field as FormField, + Control as FormControl, + Description as FormDescription, + Label as FormLabel, + FieldErrors as FormFieldErrors, + Fieldset as FormFieldset, + Legend as FormLegend, + ElementField as FormElementField, + Button as FormButton +}; diff --git a/src/lib/dto/contest.ts b/src/lib/dto/contest.ts index b916399..48afa61 100644 --- a/src/lib/dto/contest.ts +++ b/src/lib/dto/contest.ts @@ -186,9 +186,23 @@ export interface TaskResult { bestSubmissionId: number; } +export interface ContestLeaderboardEntry { + user: UserInfo; + tasksSolved: number; + tasksPartiallySolved: number; + tasksAttempted: number; + taskBreakdown: TaskResult[]; +} + +export interface ContestMyResults { + taskResults: TaskResult[]; +} + export interface ContestResults { contest: BaseContest; taskResults: TaskResult[]; + leaderboard?: ContestLeaderboardEntry[]; + myResults?: ContestMyResults; } export interface UserTaskPerformance { diff --git a/src/routes/(landing)/register/register.remote.ts b/src/lib/schemas/auth.ts similarity index 50% rename from src/routes/(landing)/register/register.remote.ts rename to src/lib/schemas/auth.ts index 0147e28..50342a9 100644 --- a/src/routes/(landing)/register/register.remote.ts +++ b/src/lib/schemas/auth.ts @@ -1,14 +1,27 @@ import * as v from 'valibot'; -import { error, isHttpError, redirect } from '@sveltejs/kit'; -import { form, getRequestEvent } from '$app/server'; -import { AuthService } from '$lib/services/AuthService'; -import { createApiClient } from '$lib/services/ApiService'; -import { TokenManager } from '$lib/token'; -import { localizeUrl } from '$lib/paraglide/runtime'; -import { AppRoutes } from '$lib/routes'; import * as m from '$lib/paraglide/messages'; -const RegisterSchema = v.pipe( +/** + * 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()), @@ -35,7 +48,7 @@ const RegisterSchema = v.pipe( 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( + password: v.pipe( v.string(m.validation_password_required()), v.nonEmpty(m.validation_password_required()), v.minLength(8, m.validation_password_min()), @@ -45,52 +58,20 @@ const RegisterSchema = v.pipe( v.regex(/[0-9]/, m.validation_password_digit()), v.regex(/[!#?@$%^&*-]/, m.validation_password_special()) ), - _confirmPassword: v.pipe( + 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, + [['password'], ['confirmPassword']], + (input) => input.password === input.confirmPassword, m.validation_passwords_match() ), - ['_confirmPassword'] + ['confirmPassword'] ) ); -type RegisterData = v.InferOutput; - -export const register = form(RegisterSchema, async (data: RegisterData) => { - const event = getRequestEvent(); - - const apiClient = createApiClient(event.cookies); - const authService = new AuthService(apiClient); - const localizedUrl = localizeUrl(new URL(AppRoutes.Dashboard, event.url.origin)); - try { - const result = await authService.register({ - email: data.email, - name: data.name, - surname: data.surname, - username: data.username, - password: data._password, - confirmPassword: data._confirmPassword - }); - - if (!result.success) { - error(result.status, { message: result.error || m.error_default_message() }); - } - if (result.data) { - TokenManager.setAccessToken(event.cookies, result.data); - } - } catch (err) { - if (isHttpError(err)) { - error(err.status, { message: err.body.message }); - } else { - const errorMessage = err instanceof Error ? err.message : m.error_default_message(); - error(500, { message: errorMessage }); - } - } - redirect(303, localizedUrl.pathname); -}); +export type LoginInput = v.InferInput; +export type RegisterInput = v.InferInput; diff --git a/src/lib/schemas/collaborator.ts b/src/lib/schemas/collaborator.ts new file mode 100644 index 0000000..4c1d09d --- /dev/null +++ b/src/lib/schemas/collaborator.ts @@ -0,0 +1,32 @@ +import * as v from 'valibot'; +import { Permission } from '$lib/dto/accessControl'; + +/** + * Schema for adding a collaborator to task/contest/group + */ +export const AddCollaboratorSchema = v.object({ + resourceId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid resource ID')), + userId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID')), + permission: v.enum(Permission, 'Invalid permission level') +}); + +/** + * Schema for updating collaborator permissions + */ +export const UpdateCollaboratorSchema = v.object({ + resourceId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid resource ID')), + userId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID')), + permission: v.enum(Permission, 'Invalid permission level') +}); + +/** + * Schema for removing a collaborator + */ +export const RemoveCollaboratorSchema = v.object({ + resourceId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid resource ID')), + userId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID')) +}); + +export type AddCollaboratorInput = v.InferOutput; +export type UpdateCollaboratorInput = v.InferOutput; +export type RemoveCollaboratorInput = v.InferOutput; diff --git a/src/lib/schemas/contest.ts b/src/lib/schemas/contest.ts new file mode 100644 index 0000000..5623388 --- /dev/null +++ b/src/lib/schemas/contest.ts @@ -0,0 +1,74 @@ +import * as v from 'valibot'; + +/** + * Schema for creating a new contest + */ +export const CreateContestSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty('Contest name is required')), + description: v.pipe(v.string(), v.nonEmpty('Description is required')), + startAt: v.pipe(v.string(), v.nonEmpty('Start date is required')), + endAt: v.optional(v.string()), + isRegistrationOpen: v.boolean(), + isSubmissionOpen: v.boolean(), + isVisible: v.boolean() +}); + +/** + * Schema for updating an existing contest + */ +export const UpdateContestSchema = v.object({ + id: v.pipe(v.number(), v.integer()), + name: v.pipe(v.string(), v.nonEmpty('Contest name is required')), + description: v.pipe(v.string(), v.nonEmpty('Description is required')), + startAt: v.pipe(v.string(), v.nonEmpty('Start date is required')), + endAt: v.optional(v.string()), + isRegistrationOpen: v.boolean(), + isSubmissionOpen: v.boolean(), + isVisible: v.boolean() +}); + +export type CreateContestInput = v.InferOutput; +export type UpdateContestInput = v.InferOutput; + +/** + * Schema for adding groups to a contest + */ +export const AddGroupsToContestSchema = v.object({ + contestId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid contest ID')), + groupIds: v.pipe( + v.array(v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid group ID'))), + v.minLength(1, 'At least one group must be selected') + ) +}); + +/** + * Schema for removing groups from a contest + */ +export const RemoveGroupsFromContestSchema = v.object({ + contestId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid contest ID')), + groupIds: v.pipe( + v.array(v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid group ID'))), + v.minLength(1, 'At least one group must be selected') + ) +}); + +/** + * Schema for adding a task to a contest + */ +export const AddTaskToContestSchema = v.object({ + contestId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid contest ID')), + taskId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid task ID')) +}); + +/** + * Schema for removing a task from a contest + */ +export const RemoveTaskFromContestSchema = v.object({ + contestId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid contest ID')), + taskId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid task ID')) +}); + +export type AddGroupsToContestInput = v.InferOutput; +export type RemoveGroupsFromContestInput = v.InferOutput; +export type AddTaskToContestInput = v.InferOutput; +export type RemoveTaskFromContestInput = v.InferOutput; diff --git a/src/lib/schemas/group.ts b/src/lib/schemas/group.ts new file mode 100644 index 0000000..acb013e --- /dev/null +++ b/src/lib/schemas/group.ts @@ -0,0 +1,55 @@ +import * as v from 'valibot'; +import * as m from '$lib/paraglide/messages'; + +/** + * Schema for creating a new group + */ +export const CreateGroupSchema = v.object({ + name: v.pipe( + v.string(), + v.nonEmpty(m.groups_form_name_required()), + v.minLength(3, m.groups_form_name_min_length()), + v.maxLength(50, m.groups_form_name_max_length()) + ) +}); + +export type CreateGroupInput = v.InferOutput; + +/** + * Schema for editing a group + */ +export const UpdateGroupSchema = v.object({ + groupId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid group ID')), + name: v.pipe( + v.string(), + v.nonEmpty(m.groups_form_name_required()), + v.minLength(3, m.groups_form_name_min_length()), + v.maxLength(50, m.groups_form_name_max_length()) + ) +}); + +/** + * Schema for adding users to a group + */ +export const AddUsersToGroupSchema = v.object({ + groupId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid group ID')), + userIds: v.pipe( + v.array(v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID'))), + v.minLength(1, 'At least one user must be selected') + ) +}); + +/** + * Schema for removing users from a group + */ +export const RemoveUsersFromGroupSchema = v.object({ + groupId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid group ID')), + userIds: v.pipe( + v.array(v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID'))), + v.minLength(1, 'At least one user must be selected') + ) +}); + +export type UpdateGroupInput = v.InferOutput; +export type AddUsersToGroupInput = v.InferOutput; +export type RemoveUsersFromGroupInput = v.InferOutput; diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts new file mode 100644 index 0000000..7532381 --- /dev/null +++ b/src/lib/schemas/index.ts @@ -0,0 +1,64 @@ +// Auth schemas +export { LoginSchema, RegisterSchema, type LoginInput, type RegisterInput } from './auth'; + +// Submission schemas +export { + SubmitSolutionSchema, + SubmitContestSolutionSchema, + type SubmitSolutionInput, + type SubmitContestSolutionInput +} from './submission'; + +// User schemas +export { + ChangePasswordSchema, + UpdateUserSchema, + type ChangePasswordInput, + type UpdateUserInput +} from './user'; + +// Task schemas +export { + UploadTaskSchema, + UpdateTaskLimitsSchema, + type UploadTaskInput, + type UpdateTaskLimitsInput +} from './task'; + +// Group schemas +export { + CreateGroupSchema, + UpdateGroupSchema, + AddUsersToGroupSchema, + RemoveUsersFromGroupSchema, + type CreateGroupInput, + type UpdateGroupInput, + type AddUsersToGroupInput, + type RemoveUsersFromGroupInput +} from './group'; + +// Contest schemas +export { + CreateContestSchema, + UpdateContestSchema, + AddGroupsToContestSchema, + RemoveGroupsFromContestSchema, + AddTaskToContestSchema, + RemoveTaskFromContestSchema, + type CreateContestInput, + type UpdateContestInput, + type AddGroupsToContestInput, + type RemoveGroupsFromContestInput, + type AddTaskToContestInput, + type RemoveTaskFromContestInput +} from './contest'; + +// Collaborator schemas +export { + AddCollaboratorSchema, + UpdateCollaboratorSchema, + RemoveCollaboratorSchema, + type AddCollaboratorInput, + type UpdateCollaboratorInput, + type RemoveCollaboratorInput +} from './collaborator'; diff --git a/src/lib/schemas/submission.ts b/src/lib/schemas/submission.ts new file mode 100644 index 0000000..6783db7 --- /dev/null +++ b/src/lib/schemas/submission.ts @@ -0,0 +1,24 @@ +import * as v from 'valibot'; + +/** + * Schema for submitting a solution to a task + */ +export const SubmitSolutionSchema = v.object({ + taskId: v.pipe(v.number(), v.minValue(1)), + solution: v.instance(File, 'Solution file is required'), + languageId: v.pipe(v.number(), v.minValue(1)) +}); + +export type SubmitSolutionInput = v.InferOutput; + +/** + * Schema for submitting a solution to a contest task + */ +export const SubmitContestSolutionSchema = v.object({ + contestId: v.pipe(v.number(), v.minValue(1)), + taskId: v.pipe(v.number(), v.minValue(1)), + solution: v.instance(File, 'Solution file is required'), + languageId: v.pipe(v.number(), v.minValue(1)) +}); + +export type SubmitContestSolutionInput = v.InferOutput; diff --git a/src/lib/schemas/task.ts b/src/lib/schemas/task.ts new file mode 100644 index 0000000..fd2e654 --- /dev/null +++ b/src/lib/schemas/task.ts @@ -0,0 +1,32 @@ +import * as v from 'valibot'; + +/** + * Schema for uploading a new task with archive + */ +export const UploadTaskSchema = v.object({ + title: v.pipe(v.string('Title is required'), v.nonEmpty('Title cannot be empty')), + archive: v.instance(File, 'Task archive is required'), + isVisible: v.optional(v.boolean(), false) +}); + +export type UploadTaskInput = v.InferOutput; + +/** + * Schema for updating task execution limits + */ +export const UpdateTaskLimitsSchema = v.object({ + taskId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid task ID')), + languageId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid language ID')), + timeLimit: v.pipe( + v.number(), + v.minValue(100, 'Time limit must be at least 100ms'), + v.maxValue(60000, 'Time limit cannot exceed 60 seconds') + ), + memoryLimit: v.pipe( + v.number(), + v.minValue(1, 'Memory limit must be at least 1MB'), + v.maxValue(1024, 'Memory limit cannot exceed 1024MB') + ) +}); + +export type UpdateTaskLimitsInput = v.InferOutput; diff --git a/src/lib/schemas/user.ts b/src/lib/schemas/user.ts new file mode 100644 index 0000000..9d2fef2 --- /dev/null +++ b/src/lib/schemas/user.ts @@ -0,0 +1,65 @@ +import * as v from 'valibot'; +import { UserRole } from '$lib/dto/jwt'; + +/** + * Schema for changing user password + * Includes password complexity requirements and confirmation matching + */ +export const ChangePasswordSchema = v.pipe( + v.object({ + oldPassword: v.pipe(v.string(), v.nonEmpty('Current password is required')), + newPassword: v.pipe( + v.string(), + v.minLength(8, 'Password must be at least 8 characters'), + v.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'), + v.regex(/[a-z]/, 'Password must contain at least one lowercase letter'), + v.regex(/\d/, 'Password must contain at least one digit'), + v.regex(/[^A-Za-z\d]/, 'Password must contain at least one special character') + ), + newPasswordConfirm: v.pipe(v.string(), v.nonEmpty('Please confirm your new password')) + }), + v.forward( + v.partialCheck( + [['newPassword'], ['newPasswordConfirm']], + (input) => input.newPassword === input.newPasswordConfirm, + 'Passwords do not match' + ), + ['newPasswordConfirm'] + ) +); + +export type ChangePasswordInput = v.InferOutput; + +/** + * Schema for updating user details (admin only) + */ +export const UpdateUserSchema = v.object({ + userId: v.pipe(v.number(), v.integer(), v.minValue(1, 'Invalid user ID')), + name: v.pipe( + v.string(), + v.nonEmpty('Name is required'), + v.minLength(3, 'Name must be at least 3 characters'), + v.maxLength(50, 'Name cannot exceed 50 characters') + ), + surname: v.pipe( + v.string(), + v.nonEmpty('Surname is required'), + v.minLength(3, 'Surname must be at least 3 characters'), + v.maxLength(50, 'Surname cannot exceed 50 characters') + ), + username: v.pipe( + v.string(), + v.nonEmpty('Username is required'), + v.minLength(3, 'Username must be at least 3 characters'), + v.maxLength(30, 'Username cannot exceed 30 characters'), + v.regex(/^[a-zA-Z]/, 'Username must start with a letter'), + v.regex( + /^[a-zA-Z][a-zA-Z0-9_]*$/, + 'Username can only contain letters, numbers, and underscores' + ) + ), + email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')), + role: v.picklist([UserRole.Student, UserRole.Teacher, UserRole.Admin], 'Invalid role') +}); + +export type UpdateUserInput = v.InferOutput; diff --git a/src/lib/services/ApiService.ts b/src/lib/services/ApiService.ts index 74571cd..282fa22 100644 --- a/src/lib/services/ApiService.ts +++ b/src/lib/services/ApiService.ts @@ -1,5 +1,5 @@ import { TokenManager } from '../token'; -import { env } from '$env/dynamic/private'; +import { env } from '$env/dynamic/public'; import { goto } from '$app/navigation'; import type { AuthTokenData } from '../dto/auth'; import { RequestMethod, RequestContentType, type Request } from '../dto/request'; @@ -74,7 +74,7 @@ export class ApiService { private refreshPromise: Promise | null = null; private cookies: Cookies | null = null; - constructor(baseUrl: string = env.BACKEND_API_URL || 'http://localhost:8000/api/v1') { + constructor(baseUrl: string = env.PUBLIC_BACKEND_API_URL || 'http://localhost:8000/api/v1') { this.baseUrl = baseUrl; } diff --git a/src/lib/services/ContestsManagementService.ts b/src/lib/services/ContestsManagementService.ts index ace65df..2be9d25 100644 --- a/src/lib/services/ContestsManagementService.ts +++ b/src/lib/services/ContestsManagementService.ts @@ -23,22 +23,6 @@ export class ContestsManagementService { this.apiClient = createApiClient(cookies); } - async getCreatedContests(): Promise { - try { - const contests = await this.apiClient.get>>({ - url: '/contests-management/contests/created' - }); - - return contests.data.items; - } catch (error) { - if (error instanceof ApiError) { - console.error('Failed to get created contests:', error.toJSON()); - throw error; - } - throw error; - } - } - async getManagedContests(): Promise { try { const contests = await this.apiClient.get>>({ @@ -48,7 +32,7 @@ export class ContestsManagementService { return contests.data.items; } catch (error) { if (error instanceof ApiError) { - console.error('Failed to get created contests:', error.toJSON()); + console.error('Failed to get managed contests:', error.toJSON()); throw error; } throw error; diff --git a/src/lib/services/api/AccessControlService.ts b/src/lib/services/api/AccessControlService.ts new file mode 100644 index 0000000..9c76e22 --- /dev/null +++ b/src/lib/services/api/AccessControlService.ts @@ -0,0 +1,187 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; +import type { + Collaborator, + AddCollaboratorRequest, + UpdateCollaboratorRequest +} from '$lib/dto/accessControl'; +import { ResourceType, Permission } from '$lib/dto/accessControl'; +import type { User } from '$lib/dto/user'; + +/** + * Client-side service for access control API calls + * Mirrors the server-side AccessControlService API + */ +export class AccessControlService { + constructor(private apiClient: ApiService) {} + + /** + * Get assignable users for a resource. + * Returns users (teachers) who can be granted access to the resource. + * Only users with manage permission can view assignable users. + * Returned users do not currently have any access entry for the resource. + */ + async getAssignableUsers( + resourceType: ResourceType, + resourceId: number, + params?: { limit?: number; offset?: number; sort?: string } + ): Promise<{ + success: boolean; + status: number; + data?: PaginatedData; + error?: string; + }> { + try { + const queryParams = new URLSearchParams(); + if (params?.limit !== undefined) { + queryParams.append('limit', params.limit.toString()); + } + if (params?.offset !== undefined) { + queryParams.append('offset', params.offset.toString()); + } + if (params?.sort) { + queryParams.append('sort', params.sort); + } + + const url = `/access-control/resources/${resourceType}/${resourceId}/assignable${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await this.apiClient.get>>({ + url + }); + 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; + } + } + + /** + * Get collaborators for a resource. + * Only users with edit permission or higher can see collaborators. + */ + async getCollaborators( + resourceType: ResourceType, + resourceId: number + ): Promise<{ + success: boolean; + status: number; + data?: Collaborator[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/access-control/resources/${resourceType}/${resourceId}/collaborators` + }); + 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; + } + } + + /** + * Add a collaborator to a resource. + * Only users with manage permission can add collaborators. + */ + async addCollaborator( + resourceType: ResourceType, + resourceId: number, + data: { userId: number; permission: Permission.Edit | Permission.Manage } + ): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.post>({ + url: `/access-control/resources/${resourceType}/${resourceId}/collaborators`, + body: JSON.stringify({ user_id: data.userId, permission: data.permission }) + }); + return { success: true, status: 201 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + /** + * Update a collaborator's permission on a resource. + * Only users with manage permission can update collaborators. + */ + async updateCollaborator( + resourceType: ResourceType, + resourceId: number, + userId: number, + data: { permission: Permission.Edit | Permission.Manage } + ): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.put>({ + url: `/access-control/resources/${resourceType}/${resourceId}/collaborators/${userId}`, + body: JSON.stringify(data) + }); + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + /** + * Remove a collaborator from a resource. + * Only users with manage or owner permission can remove collaborators. + * Managers can only remove editors and managers, owners can remove everyone except other owners. + */ + async deleteCollaborator( + resourceType: ResourceType, + resourceId: number, + userId: number + ): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.delete>({ + url: `/access-control/resources/${resourceType}/${resourceId}/collaborators/${userId}` + }); + 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/api/ApiService.ts b/src/lib/services/api/ApiService.ts new file mode 100644 index 0000000..8a1b36c --- /dev/null +++ b/src/lib/services/api/ApiService.ts @@ -0,0 +1,284 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import { RequestMethod, RequestContentType, type Request } from '../../dto/request'; +import { isApiErrorResponse } from '../../dto/error'; +import { AppRoutes } from '$lib/routes'; +import { ApiError } from '../ApiService'; +import { tokenStore } from '$lib/stores/token-store.svelte'; +import type { ApiResponse } from '../../dto/response'; +import type { AuthTokenData } from '../../dto/auth'; + +/** + * Client-side API service for browser contexts + * Uses in-memory token storage with refresh token in HttpOnly cookie + * - Access token: stored in memory (cleared on page refresh) + * - Refresh token: stored in HttpOnly cookie by backend (secure, persistent) + * - Silent refresh: automatically fetches new access token on page load + */ +export class ApiService { + private baseUrl: string; + private isRefreshing = false; + private refreshPromise: Promise | null = null; + + constructor(baseUrl: string) { + if (!browser) { + throw new Error('ApiService 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 + * Returns the new access token + */ + 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' + }); + + if (!response.ok) { + console.warn('Token refresh failed, redirecting to login.'); + tokenStore.clearAccessToken(); + goto(AppRoutes.Login); + throw new Error('Token refresh failed'); + } + + // Backend returns new access token in response body + const data = (await response.json()) as ApiResponse; + if (data.data?.accessToken) { + tokenStore.setAccessToken(data.data.accessToken); + } + } finally { + this.isRefreshing = false; + this.refreshPromise = null; + } + })(); + + return this.refreshPromise; + } + + /** + * Silent refresh - get new access token on app initialization + * Call this on page load to restore authentication state + */ + async silentRefresh(): Promise { + try { + await this.refreshToken(); + return tokenStore.hasToken(); + } catch (error) { + return false; + } + } + + /** + * Make an HTTP request to the API + * Automatically includes access token from memory 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); + } + + // Add access token from memory if available + const token = tokenStore.getAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + // Make the request with credentials to include HttpOnly refresh token cookie + let response = await fetch(`${this.baseUrl}${request.url}`, { + method: request.method, + body: request.body, + ...request.options, + headers, + credentials: 'include' + }); + + // Handle 401 Unauthorized - try to refresh token + // Skip refresh for auth endpoints to prevent infinite loops + if (response.status === 401 && !request.url.includes('/auth')) { + try { + await this.refreshToken(); + + // Retry the original request with new token + const newToken = tokenStore.getAccessToken(); + if (newToken) { + headers.set('Authorization', `Bearer ${newToken}`); + } + + 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(); + } +} diff --git a/src/lib/services/api/AuthService.ts b/src/lib/services/api/AuthService.ts new file mode 100644 index 0000000..51e7df1 --- /dev/null +++ b/src/lib/services/api/AuthService.ts @@ -0,0 +1,119 @@ +import { ApiError } from '../ApiService'; +import type { ApiService } from './ApiService'; +import type { AuthTokenData } from '../../dto/auth'; +import type { UserLoginDto, UserRegisterDto } from '../../dto/user'; +import { RequestContentType } from '../../dto/request'; +import type { ApiResponse } from '../../dto/response'; +import { tokenStore } from '$lib/stores/token-store.svelte'; +import { userStore } from '$lib/stores/user-store.svelte'; + +/** + * Client-side authentication service + * Uses in-memory token storage with HttpOnly refresh token cookie + * - Access token: stored in memory (better security, cleared on refresh) + * - Refresh token: stored in HttpOnly cookie by backend (secure, persistent) + */ +export class AuthService { + constructor(private apiClient: ApiService) {} + + 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 returns access token in response body and sets refresh token in HttpOnly cookie + if (response.data?.accessToken) { + tokenStore.setAccessToken(response.data.accessToken); + } + + 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 returns access token in response body and sets refresh token in HttpOnly cookie + if (response.data?.accessToken) { + tokenStore.setAccessToken(response.data.accessToken); + } + + 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' + }); + + // Clear in-memory token and user, backend clears HttpOnly refresh cookie + tokenStore.clearAccessToken(); + userStore.clearUser(); + 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' + }); + + 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/api/ContestService.ts b/src/lib/services/api/ContestService.ts new file mode 100644 index 0000000..390cfad --- /dev/null +++ b/src/lib/services/api/ContestService.ts @@ -0,0 +1,264 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { + Contest, + ContestWithStats, + PastContestWithStats, + ContestResults, + ContestDetailed +} from '$lib/dto/contest'; +import type { ContestTaskWithStatistics, TaskDetail } from '$lib/dto/task'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; + +/** + * Client-side service for contest-related API calls + * Mirrors the server-side ContestService API (throws errors) + */ +export class ContestService { + constructor(private apiClient: ApiService) {} + + async getUserContests(): Promise<{ + success: boolean; + status: number; + data?: { + active: ContestWithStats[]; + upcoming: ContestWithStats[]; + past: PastContestWithStats[]; + }; + error?: string; + }> { + try { + const response = await this.apiClient.get< + ApiResponse<{ + ongoing: ContestWithStats[]; + upcoming: ContestWithStats[]; + past: PastContestWithStats[]; + }> + >({ + url: '/contests/my' + }); + + const ongoing = response.data.ongoing ?? []; + const upcoming = response.data.upcoming ?? []; + const past = response.data.past ?? []; + + return { + success: true, + status: 200, + data: { + active: [...ongoing, ...upcoming], + upcoming, + past + } + }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async getOngoing(): Promise { + try { + const response = await this.apiClient.get>>({ + url: '/contests?status=ongoing' + }); + return response.data.items; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get ongoing contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getUpcoming(): Promise { + try { + const response = await this.apiClient.get>>({ + url: '/contests?status=upcoming' + }); + return response.data.items; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get upcoming contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getPast(): Promise { + try { + const response = await this.apiClient.get>>({ + url: '/contests?status=past' + }); + return response.data.items; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get past contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getMyActiveContests(): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests/my/active` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get active contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getMyPastContests(): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests/my/past` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get past contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async registerForContest(contestId: number): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.post>({ + url: `/contests/${contestId}/register` + }); + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async getContestTasksWithStatistics(contestId: number): Promise<{ + success: boolean; + status: number; + data?: ContestTaskWithStatistics[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}/tasks/user-statistics` + }); + 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 getContestTask( + contestId: number, + taskId: number + ): Promise<{ + success: boolean; + status: number; + data?: TaskDetail; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}/tasks/${taskId}` + }); + 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 getContest(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getMyResults(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}/results/my` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest results:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getContestResults(contestId: number): Promise<{ + success: boolean; + status: number; + data?: ContestResults; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}/results/my` + }); + 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; + } + } +} diff --git a/src/lib/services/api/ContestsManagementService.ts b/src/lib/services/api/ContestsManagementService.ts new file mode 100644 index 0000000..ef4a7bf --- /dev/null +++ b/src/lib/services/api/ContestsManagementService.ts @@ -0,0 +1,325 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { + CreatedContest, + CreateContestDto, + EditContestDto, + RegistrationRequest, + AddContestTaskDto, + ContestTask as ContestTaskRelation, + ManagedContest, + UserContestStats, + TaskUserStats +} from '$lib/dto/contest'; +import type { Task, ContestTask } from '$lib/dto/task'; +import type { Group } from '$lib/dto/group'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; +import type { Submission, GetContestSubmissionsParams } from '$lib/dto/submission'; + +/** + * Client-side service for contest management API calls + * Mirrors the server-side ContestsManagementService API (throws errors) + */ +export class ContestsManagementService { + constructor(private apiClient: ApiService) {} + + async getManagedContests(): Promise { + try { + const contests = await this.apiClient.get>>({ + url: '/contests-management/contests/managed' + }); + return contests.data.items; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get managed contests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async createContest(data: CreateContestDto): Promise<{ id: number }> { + try { + const requestData = { + ...data, + startAt: data.startAt, + endAt: data.endAt ? data.endAt : null + }; + + const response = await this.apiClient.post>({ + url: '/contests-management/contests', + body: JSON.stringify(requestData) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to create contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async updateContest(id: number, data: EditContestDto): Promise { + try { + const requestData = { + ...data, + startAt: data.startAt, + endAt: data.endAt ? data.endAt : null + }; + + const response = await this.apiClient.put>({ + url: `/contests-management/contests/${id}`, + body: JSON.stringify(requestData) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to update contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getRegistrationRequests( + contestId: number, + status: string = 'pending' + ): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/registration-requests?status=${status}` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get registration requests:', error.toJSON()); + throw error; + } + throw error; + } + } + + async approveRegistrationRequest(contestId: number, userId: number): Promise { + try { + await this.apiClient.post>({ + url: `/contests-management/contests/${contestId}/registration-requests/${userId}/approve` + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to approve registration request:', error.toJSON()); + throw error; + } + throw error; + } + } + + async rejectRegistrationRequest(contestId: number, userId: number): Promise { + try { + await this.apiClient.post>({ + url: `/contests-management/contests/${contestId}/registration-requests/${userId}/reject` + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to reject registration request:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getAssignableTasks(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/tasks/assignable-tasks` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get assignable tasks:', error.toJSON()); + throw error; + } + throw error; + } + } + + async addTaskToContest(contestId: number, data: AddContestTaskDto): Promise { + try { + const requestData = { + taskId: data.taskId, + startAt: data.startAt, + endAt: data.endAt ? data.endAt : null + }; + const response = await this.apiClient.post>({ + url: `/contests-management/contests/${contestId}/tasks`, + body: JSON.stringify(requestData) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to add task to contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async removeTaskFromContest(contestId: number, taskIds: number | number[]): Promise { + try { + const requestData = { + taskIds: Array.isArray(taskIds) ? taskIds : [taskIds] + }; + await this.apiClient.delete>({ + url: `/contests-management/contests/${contestId}/tasks`, + body: JSON.stringify(requestData) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove task from contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getContestTasks(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/tasks` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest tasks:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getContestSubmissions( + contestId: number, + params?: GetContestSubmissionsParams + ): Promise> { + try { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.offset) queryParams.append('offset', params.offset.toString()); + if (params?.sort) queryParams.append('sort', params.sort); + + const url = `/contests-management/contests/${contestId}/submissions${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + const response = await this.apiClient.get>>({ + url + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest submissions:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getUserStats(contestId: number, userId?: number): Promise { + try { + const queryParams = new URLSearchParams(); + if (userId) queryParams.append('userId', userId.toString()); + + const url = `/contests-management/contests/${contestId}/user-stats${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + const response = await this.apiClient.get>({ + url + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest user stats:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getTaskUserStats(contestId: number, taskId: number): Promise { + try { + const url = `/contests-management/contests/${contestId}/tasks/${taskId}/user-stats`; + + const response = await this.apiClient.get>({ + url + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get task user stats:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getContestGroups(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/groups` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest groups:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getAssignableGroups(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/groups/assignable` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get assignable groups:', error.toJSON()); + throw error; + } + throw error; + } + } + + async addGroupsToContest(contestId: number, groupIds: number[]): Promise { + try { + await this.apiClient.post>({ + url: `/contests-management/contests/${contestId}/groups`, + body: JSON.stringify({ groupIds }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to add groups to contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async removeGroupsFromContest(contestId: number, groupIds: number[]): Promise { + try { + await this.apiClient.delete>({ + url: `/contests-management/contests/${contestId}/groups`, + body: JSON.stringify({ groupIds }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove groups from contest:', error.toJSON()); + throw error; + } + throw error; + } + } +} diff --git a/src/lib/services/api/GroupsManagementService.ts b/src/lib/services/api/GroupsManagementService.ts new file mode 100644 index 0000000..f3ab07b --- /dev/null +++ b/src/lib/services/api/GroupsManagementService.ts @@ -0,0 +1,130 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { Group, CreateGroupDto, EditGroupDto } from '$lib/dto/group'; +import type { User } from '$lib/dto/user'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; + +/** + * Client-side service for group management API calls + * Mirrors the server-side GroupsManagementService API (throws errors) + */ +export class GroupsManagementService { + constructor(private apiClient: ApiService) {} + + async getAllGroups(): Promise<{ + success: boolean; + status: number; + data?: Group[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>>({ + url: '/groups-management/groups' + }); + const payload = response.data; + const groups = Array.isArray(payload) ? payload : (payload.items ?? []); + return { success: true, data: groups, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async getGroupById(groupId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/groups-management/groups/${groupId}` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async createGroup(data: CreateGroupDto): Promise<{ id: number }> { + try { + const response = await this.apiClient.post>({ + url: '/groups-management/groups', + body: JSON.stringify(data) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to create group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async updateGroup(groupId: number, data: EditGroupDto): Promise { + try { + const response = await this.apiClient.put>({ + url: `/groups-management/groups/${groupId}`, + body: JSON.stringify(data) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to update group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getGroupMembers(groupId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/groups-management/groups/${groupId}/users` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get group members:', error.toJSON()); + throw error; + } + throw error; + } + } + + async addUsersToGroup(groupId: number, userIDs: number[]): Promise { + try { + await this.apiClient.post>({ + url: `/groups-management/groups/${groupId}/users`, + body: JSON.stringify({ userIDs }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to add users to group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async removeUsersFromGroup(groupId: number, userIDs: number[]): Promise { + try { + await this.apiClient.delete>({ + url: `/groups-management/groups/${groupId}/users`, + body: JSON.stringify({ userIDs }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove users from group:', error.toJSON()); + throw error; + } + throw error; + } + } +} diff --git a/src/lib/services/api/SubmissionService.ts b/src/lib/services/api/SubmissionService.ts new file mode 100644 index 0000000..42c1e34 --- /dev/null +++ b/src/lib/services/api/SubmissionService.ts @@ -0,0 +1,124 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; +import type { + Language, + SubmitSolutionDto, + Submission, + SubmissionDetailed +} from '$lib/dto/submission'; + +/** + * Client-side service for submission-related API calls + * Mirrors the server-side SubmissionService API + */ +export class SubmissionService { + constructor(private apiClient: ApiService) {} + + async submitSolution(body: SubmitSolutionDto): Promise<{ + success: boolean; + status: number; + data?: null; + error?: string; + }> { + const formData = new FormData(); + formData.append('taskID', body.taskID.toString()); + formData.append('solution', body.solution); + formData.append('languageID', body.languageID.toString()); + if (body.contestID !== undefined) { + formData.append('contestID', body.contestID.toString()); + } + + try { + const response = await this.apiClient.post>({ + url: '/submissions/submit', + body: formData + }); + 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 getAvailableLanguages(): Promise<{ + success: boolean; + status: number; + data?: Language[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: '/submissions/languages' + }); + 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 getMySubmissions(params?: { limit?: number; offset?: number }): Promise<{ + success: boolean; + status: number; + data?: Submission[]; + error?: string; + }> { + try { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.offset) queryParams.append('offset', params.offset.toString()); + + const url = `/submissions/my${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const response = await this.apiClient.get>>({ + url + }); + return { success: true, data: response.data.items, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async getSubmissionById(submissionId: number): Promise<{ + success: boolean; + status: number; + data?: SubmissionDetailed; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/submissions/${submissionId}` + }); + 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; + } + } +} diff --git a/src/lib/services/api/TaskService.ts b/src/lib/services/api/TaskService.ts new file mode 100644 index 0000000..977ef38 --- /dev/null +++ b/src/lib/services/api/TaskService.ts @@ -0,0 +1,130 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { Task, TaskDetail, MyTasksResponse } from '$lib/dto/task'; +import type { ApiResponse } from '$lib/dto/response'; + +/** + * Client-side service for task-related API calls + * Mirrors the server-side TaskService API + */ +export class TaskService { + constructor(private apiClient: ApiService) {} + + /** + * Get all available tasks + * @returns List of all tasks + */ + async getAllTasks(): Promise<{ + success: boolean; + status: number; + data?: Task[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: '/tasks' + }); + 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; + } + } + + /** + * Get task details by ID + * @param id - Task ID + * @returns Task details + */ + async getTaskById(id: number): Promise<{ + success: boolean; + status: number; + data?: TaskDetail; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/tasks/${id}` + }); + 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; + } + } + + /** + * Get current user's tasks with optional pagination + * @param params - Optional pagination parameters + * @returns User's tasks with pagination metadata + */ + async getMyTasks(params?: { limit?: number; offset?: number }): Promise<{ + success: boolean; + status: number; + data?: MyTasksResponse; + error?: string; + }> { + try { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.offset) queryParams.append('offset', params.offset.toString()); + + const url = `/tasks/my${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const response = await this.apiClient.get>({ + url + }); + 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; + } + } + + /** + * Get contest task details by contest and task ID + */ + async getContestTask( + contestId: number, + taskId: number + ): Promise<{ + success: boolean; + status: number; + data?: TaskDetail; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/contests/${contestId}/tasks/${taskId}` + }); + 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; + } + } +} diff --git a/src/lib/services/api/TasksManagementService.ts b/src/lib/services/api/TasksManagementService.ts new file mode 100644 index 0000000..f97bc52 --- /dev/null +++ b/src/lib/services/api/TasksManagementService.ts @@ -0,0 +1,166 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; +import type { + Task, + UploadTaskResponse, + UploadTaskDto, + TaskLimit, + UpdateTaskLimitsDto +} from '$lib/dto/task'; +import { RequestContentType } from '$lib/dto/request'; + +/** + * Client-side service for task management API calls + * Mirrors the server-side TasksManagementService API + */ +export class TasksManagementService { + constructor(private apiClient: ApiService) {} + + async uploadTask( + body: UploadTaskDto + ): Promise<{ success: boolean; status: number; data?: UploadTaskResponse; error?: string }> { + const formData = new FormData(); + formData.append('title', body.title); + formData.append('archive', body.archive); + formData.append('isVisible', body.isVisible.toString()); + + try { + const response = await this.apiClient.post>({ + url: '/tasks-management/tasks', + body: formData + }); + 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 getCreatedTasks(): Promise<{ + success: boolean; + status: number; + data?: Task[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>>({ + url: '/tasks-management/tasks/created' + }); + return { success: true, data: response.data.items, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async getTaskLimits(taskId: number): Promise<{ + success: boolean; + status: number; + data?: TaskLimit[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: `/tasks-management/tasks/${taskId}/limits` + }); + 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 updateTaskLimits( + taskId: number, + body: UpdateTaskLimitsDto + ): Promise<{ + success: boolean; + status: number; + data?: TaskLimit[]; + error?: string; + }> { + try { + const response = await this.apiClient.put>({ + url: `/tasks-management/tasks/${taskId}/limits`, + body: JSON.stringify(body), + contentType: RequestContentType.Json + }); + 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 toggleTaskVisibility( + taskId: number, + isVisible: boolean + ): Promise<{ success: boolean; status: number; error?: string }> { + const formData = new FormData(); + formData.append('isVisible', isVisible.toString()); + + try { + await this.apiClient.patch>({ + url: `/tasks-management/tasks/${taskId}`, + body: formData + }); + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + async deleteTask(taskId: number): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.delete>({ + url: `/tasks-management/tasks/${taskId}` + }); + 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/api/UserManagementService.ts b/src/lib/services/api/UserManagementService.ts new file mode 100644 index 0000000..01bc64e --- /dev/null +++ b/src/lib/services/api/UserManagementService.ts @@ -0,0 +1,108 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { User, UserEditDto } from '$lib/dto/user'; +import type { ApiResponse, PaginatedData } from '$lib/dto/response'; +import { RequestContentType } from '$lib/dto/request'; + +/** + * Client-side service for user management (admin operations) + */ +export class UserManagementService { + constructor(private apiClient: ApiService) {} + + /** + * Get all users (paginated) + */ + async getAllUsers(): Promise<{ + success: boolean; + status: number; + data?: PaginatedData; + error?: string; + }> { + try { + const response = await this.apiClient.get>>({ + url: '/users' + }); + 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; + } + } + + /** + * Get user by ID + */ + async getUserById( + userId: number + ): Promise<{ success: boolean; status: number; data?: User; error?: string }> { + try { + const response = await this.apiClient.get>({ + url: `/users/${userId}` + }); + 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; + } + } + + /** + * Update user details (admin only) + */ + async updateUser( + userId: number, + data: UserEditDto + ): Promise<{ success: boolean; status: number; data?: User; error?: string }> { + try { + const response = await this.apiClient.put>({ + url: `/users/${userId}`, + body: JSON.stringify(data), + contentType: RequestContentType.Json + }); + 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; + } + } + + /** + * Delete user (admin only) + */ + async deleteUser(userId: number): Promise<{ success: boolean; status: number; error?: string }> { + try { + await this.apiClient.delete({ + url: `/users/${userId}` + }); + 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/api/UserService.ts b/src/lib/services/api/UserService.ts new file mode 100644 index 0000000..927d831 --- /dev/null +++ b/src/lib/services/api/UserService.ts @@ -0,0 +1,117 @@ +import { ApiError } from '../ApiService'; +import type { ApiService } from './ApiService'; +import type { User } from '../../dto/user'; +import type { ApiResponse, PaginatedData } from '../../dto/response'; +import { userStore } from '$lib/stores/user-store.svelte'; +import { RequestContentType } from '../../dto/request'; + +/** + * Client-side user service + * Uses in-memory user storage + */ +export class UserService { + constructor(private apiClient: ApiService) {} + + /** + * Fetch current authenticated user and store in userStore + */ + async getCurrentUser(): Promise<{ + success: boolean; + status: number; + data?: User; + error?: string; + }> { + try { + userStore.setLoading(true); + const response = await this.apiClient.get>({ + url: '/users/me' + }); + + if (response.data) { + userStore.setUser(response.data); + } + + 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; + } finally { + userStore.setLoading(false); + } + } + + /** + * Change user password + */ + async changePassword( + userId: number, + data: { oldPassword: string; newPassword: string } + ): Promise<{ + success: boolean; + status: number; + error?: string; + }> { + try { + await this.apiClient.put>({ + url: `/users/${userId}/password`, + body: JSON.stringify(data), + contentType: RequestContentType.Json + }); + return { success: true, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } + + /** + * Get all users (admin only) with pagination + */ + async getUsers(params?: { + limit?: number; + offset?: number; + sort?: string; + role?: string; + }): Promise<{ + success: boolean; + status: number; + data?: PaginatedData; + error?: string; + }> { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.set('limit', params.limit.toString()); + if (params?.offset) queryParams.set('offset', params.offset.toString()); + if (params?.sort) queryParams.set('sort', params.sort); + if (params?.role) queryParams.set('role', params.role); + + const url = queryParams.toString() ? `/users?${queryParams.toString()}` : '/users'; + + try { + const response = await this.apiClient.get>>({ + url + }); + 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; + } + } +} diff --git a/src/lib/services/api/WorkerService.ts b/src/lib/services/api/WorkerService.ts new file mode 100644 index 0000000..312eced --- /dev/null +++ b/src/lib/services/api/WorkerService.ts @@ -0,0 +1,35 @@ +import type { ApiService } from './ApiService'; +import { ApiError } from '../ApiService'; +import type { ApiResponse } from '$lib/dto/response'; +import type { WorkerStatus } from '$lib/dto/worker'; + +/** + * Client-side service for worker status API calls + * Mirrors the server-side WorkerService API + */ +export class WorkerService { + constructor(private apiClient: ApiService) {} + + async getWorkerStatus(): Promise<{ + success: boolean; + status: number; + data?: WorkerStatus; + error?: string; + }> { + try { + const response: ApiResponse = await this.apiClient.get({ + url: '/workers/status' + }); + 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; + } + } +} diff --git a/src/lib/services/api/index.ts b/src/lib/services/api/index.ts new file mode 100644 index 0000000..e927703 --- /dev/null +++ b/src/lib/services/api/index.ts @@ -0,0 +1,31 @@ +/** + * Client-side service exports + * All services for browser-based API calls + */ + +export { ApiService } from './ApiService'; +export { AuthService } from './AuthService'; +export { UserService } from './UserService'; +export { TaskService } from './TaskService'; +export { ContestService } from './ContestService'; +export { SubmissionService } from './SubmissionService'; +export { TasksManagementService } from './TasksManagementService'; +export { ContestsManagementService } from './ContestsManagementService'; +export { GroupsManagementService } from './GroupsManagementService'; +export { AccessControlService } from './AccessControlService'; +export { WorkerService } from './WorkerService'; + +// Re-export service instance getters for convenience +export { + getApiInstance, + getAuthInstance, + getUserInstance, + getTaskInstance, + getContestInstance, + getSubmissionInstance, + getTasksManagementInstance, + getContestsManagementInstance, + getGroupsManagementInstance, + getAccessControlInstance, + getWorkerInstance +} from '$lib/stores/service-instances.svelte'; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 075a57b..6f4b777 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -1,14 +1,32 @@ -export { ApiService, ApiError, createApiClient } from './ApiService'; -export { AccessControlService } from './AccessControlService'; -export { AuthService } from './AuthService'; -export { ContestService, createContestService } from './ContestService'; +// Re-export all services from api directory +export { ApiService } from './api/ApiService'; +export { AuthService } from './api/AuthService'; +export { UserService } from './api/UserService'; +export { UserManagementService } from './api/UserManagementService'; +export { TaskService } from './api/TaskService'; +export { ContestService } from './api/ContestService'; +export { SubmissionService } from './api/SubmissionService'; +export { TasksManagementService } from './api/TasksManagementService'; +export { ContestsManagementService } from './api/ContestsManagementService'; +export { GroupsManagementService } from './api/GroupsManagementService'; +export { AccessControlService } from './api/AccessControlService'; +export { WorkerService } from './api/WorkerService'; + +// Re-export ApiError from base service +export { ApiError } from './ApiService'; + +// Re-export service instance getters export { - ContestsManagementService, - createContestsManagementService -} from './ContestsManagementService'; -export { GroupsManagementService, createGroupsManagementService } from './GroupsManagementService'; -export { SubmissionService } from './SubmissionService'; -export { TaskService, createTaskService } from './TaskService'; -export { TasksManagementService } from './TasksManagementService'; -export { UserService } from './UserService'; -export { WorkerService } from './WorkerService'; + getApiInstance, + getAuthInstance, + getUserInstance, + getUserManagementInstance, + getTaskInstance, + getContestInstance, + getSubmissionInstance, + getTasksManagementInstance, + getContestsManagementInstance, + getGroupsManagementInstance, + getAccessControlInstance, + getWorkerInstance +} from '../stores/service-instances.svelte'; diff --git a/src/lib/stores/service-instances.svelte.ts b/src/lib/stores/service-instances.svelte.ts new file mode 100644 index 0000000..90f5d5f --- /dev/null +++ b/src/lib/stores/service-instances.svelte.ts @@ -0,0 +1,311 @@ +import { browser } from '$app/environment'; +import { ApiService } from '../services/api/ApiService'; +import { AuthService } from '../services/api/AuthService'; +import { UserService } from '../services/api/UserService'; +import { UserManagementService } from '../services/api/UserManagementService'; +import { TaskService } from '../services/api/TaskService'; +import { ContestService } from '../services/api/ContestService'; +import { SubmissionService } from '../services/api/SubmissionService'; +import { TasksManagementService } from '../services/api/TasksManagementService'; +import { ContestsManagementService } from '../services/api/ContestsManagementService'; +import { GroupsManagementService } from '../services/api/GroupsManagementService'; +import { AccessControlService } from '../services/api/AccessControlService'; +import { WorkerService } from '../services/api/WorkerService'; +import { env } from '$env/dynamic/public'; + +/** + * Global singleton instance of ApiService + * Reused across all client-side services and components + */ +let apiInstance: ApiService | null = $state(null); + +/** + * Global singleton instance of AuthService + * Reused across all client-side components + */ +let authInstance: AuthService | null = $state(null); + +/** + * Global singleton instance of UserService + * Reused across all client-side components + */ +let userInstance: UserService | null = $state(null); + +/** + * Global singleton instance of UserManagementService + * Reused across all admin components for user management + */ +let userManagementInstance: UserManagementService | null = $state(null); + +/** + * Global singleton instance of TaskService + */ +let taskInstance: TaskService | null = $state(null); + +/** + * Global singleton instance of ContestService + */ +let contestInstance: ContestService | null = $state(null); + +/** + * Global singleton instance of SubmissionService + */ +let submissionInstance: SubmissionService | null = $state(null); + +/** + * Global singleton instance of TasksManagementService + */ +let tasksManagementInstance: TasksManagementService | null = $state(null); + +/** + * Global singleton instance of ContestsManagementService + */ +let contestsManagementInstance: ContestsManagementService | null = $state(null); + +/** + * Global singleton instance of GroupsManagementService + */ +let groupsManagementInstance: GroupsManagementService | null = $state(null); + +/** + * Global singleton instance of AccessControlService + */ +let accessControlInstance: AccessControlService | null = $state(null); + +/** + * Global singleton instance of WorkerService + */ +let workerInstance: WorkerService | null = $state(null); + +/** + * Get or create the global ApiService instance + * This ensures a single instance is shared across the application + */ +export function getApiInstance(): ApiService | null { + if (!browser) { + return null; + } + + if (!apiInstance) { + const apiUrl = env.PUBLIC_BACKEND_API_URL; + if (!apiUrl) { + console.error('PUBLIC_BACKEND_API_URL is not defined'); + return null; + } + apiInstance = new ApiService(apiUrl); + } + + return apiInstance; +} + +/** + * Get or create the global AuthService instance + * This ensures a single instance is shared across the application + */ +export function getAuthInstance(): AuthService | null { + if (!browser) { + return null; + } + + if (!authInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + authInstance = new AuthService(apiClient); + } + + return authInstance; +} + +/** + * Get or create the global UserService instance + * This ensures a single instance is shared across the application + */ +export function getUserInstance(): UserService | null { + if (!browser) { + return null; + } + + if (!userInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + userInstance = new UserService(apiClient); + } + + return userInstance; +} + +/** + * Get or create the global UserManagementService instance + * This ensures a single instance is shared across the application + */ +export function getUserManagementInstance(): UserManagementService | null { + if (!browser) { + return null; + } + + if (!userManagementInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + userManagementInstance = new UserManagementService(apiClient); + } + + return userManagementInstance; +} + +/** + * Get or create the global TaskService instance + */ +export function getTaskInstance(): TaskService | null { + if (!browser) { + return null; + } + + if (!taskInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + taskInstance = new TaskService(apiClient); + } + + return taskInstance; +} + +/** + * Get or create the global ContestService instance + */ +export function getContestInstance(): ContestService | null { + if (!browser) { + return null; + } + + if (!contestInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + contestInstance = new ContestService(apiClient); + } + + return contestInstance; +} + +/** + * Get or create the global SubmissionService instance + */ +export function getSubmissionInstance(): SubmissionService | null { + if (!browser) { + return null; + } + + if (!submissionInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + submissionInstance = new SubmissionService(apiClient); + } + + return submissionInstance; +} + +/** + * Get or create the global TasksManagementService instance + */ +export function getTasksManagementInstance(): TasksManagementService | null { + if (!browser) { + return null; + } + + if (!tasksManagementInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + tasksManagementInstance = new TasksManagementService(apiClient); + } + + return tasksManagementInstance; +} + +/** + * Get or create the global ContestsManagementService instance + */ +export function getContestsManagementInstance(): ContestsManagementService | null { + if (!browser) { + return null; + } + + if (!contestsManagementInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + contestsManagementInstance = new ContestsManagementService(apiClient); + } + + return contestsManagementInstance; +} + +/** + * Get or create the global GroupsManagementService instance + */ +export function getGroupsManagementInstance(): GroupsManagementService | null { + if (!browser) { + return null; + } + + if (!groupsManagementInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + groupsManagementInstance = new GroupsManagementService(apiClient); + } + + return groupsManagementInstance; +} + +/** + * Get or create the global AccessControlService instance + */ +export function getAccessControlInstance(): AccessControlService | null { + if (!browser) { + return null; + } + + if (!accessControlInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + accessControlInstance = new AccessControlService(apiClient); + } + + return accessControlInstance; +} + +/** + * Get or create the global WorkerService instance + */ +export function getWorkerInstance(): WorkerService | null { + if (!browser) { + return null; + } + + if (!workerInstance) { + const apiClient = getApiInstance(); + if (!apiClient) { + return null; + } + workerInstance = new WorkerService(apiClient); + } + + return workerInstance; +} diff --git a/src/lib/stores/token-store.svelte.ts b/src/lib/stores/token-store.svelte.ts new file mode 100644 index 0000000..151380d --- /dev/null +++ b/src/lib/stores/token-store.svelte.ts @@ -0,0 +1,42 @@ +/** + * In-memory token storage for client-side authentication + * Tokens are stored in memory (not localStorage) for better security against XSS + * Tokens are lost on page refresh, requiring silent refresh mechanism + */ +class TokenStore { + private accessToken = $state(null); + + /** + * Set the access token in memory + */ + setAccessToken(token: string): void { + this.accessToken = token; + } + + /** + * Get the current access token from memory + */ + getAccessToken(): string | null { + return this.accessToken; + } + + /** + * Check if a valid token exists + */ + hasToken(): boolean { + const has = this.accessToken !== null; + return has; + } + + /** + * Clear the access token (e.g., on logout) + */ + clearAccessToken(): void { + this.accessToken = null; + } +} + +/** + * Singleton instance of TokenStore + */ +export const tokenStore = new TokenStore(); diff --git a/src/lib/stores/user-store.svelte.ts b/src/lib/stores/user-store.svelte.ts new file mode 100644 index 0000000..d319e1e --- /dev/null +++ b/src/lib/stores/user-store.svelte.ts @@ -0,0 +1,66 @@ +import type { User } from '$lib/dto/user'; + +/** + * In-memory user storage for client-side authentication + * User data is stored in memory using Svelte 5 runes for reactivity + * Data is lost on page refresh, requiring refetch from API + */ +class UserStore { + private user = $state(null); + private loading = $state(false); + + /** + * Set the current user in memory + */ + setUser(user: User): void { + this.user = user; + } + + /** + * Get the current user from memory + */ + tryGetUser(): User | null { + return this.user; + } + + /** + * Get the current user from memory (unsafe - assumes user exists) + * Use only in contexts where you're certain the user is logged in + */ + getUserUnsafe(): User { + return this.user!; + } + + /** + * Check if user is logged in + */ + isLoggedIn(): boolean { + return this.user !== null; + } + + /** + * Clear the user (e.g., on logout) + */ + clearUser(): void { + this.user = null; + } + + /** + * Set loading state + */ + setLoading(loading: boolean): void { + this.loading = loading; + } + + /** + * Get loading state + */ + isLoading(): boolean { + return this.loading; + } +} + +/** + * Singleton instance of UserStore + */ +export const userStore = new UserStore(); diff --git a/src/lib/utils/query.svelte.ts b/src/lib/utils/query.svelte.ts new file mode 100644 index 0000000..702c54f --- /dev/null +++ b/src/lib/utils/query.svelte.ts @@ -0,0 +1,164 @@ +import { browser } from '$app/environment'; + +/** + * Query result interface exposing reactive state + */ +export interface Query { + readonly current: T | null; + readonly loading: boolean; + readonly error: Error | null; + refresh: () => Promise; +} + +/** + * Create a reactive query that fetches data and manages loading/error states + * Works in both SSR and CSR contexts, but only auto-fetches in browser + * + * @param fetcher - Async function that fetches the data + * @returns Query object with current data, loading state, error state, and refresh function + * + * @example + * ```typescript + * const tasksQuery = createQuery(async () => { + * const service = getClientTaskInstance(); + * if (!service) throw new Error('Service unavailable'); + * const result = await service.getAllTasks(); + * if (!result.success) throw new Error(result.error || 'Failed to fetch'); + * return result.data!; + * }); + * + * // In template: + * {#if tasksQuery.error} + * tasksQuery.refresh()} /> + * {:else if tasksQuery.loading} + * + * {:else if tasksQuery.current} + * + * {/if} + * ``` + */ +export function createQuery(fetcher: () => Promise): Query { + let current = $state(null); + let loading = $state(true); + let error = $state(null); + + async function fetchData(): Promise { + loading = true; + error = null; + try { + const data = await fetcher(); + current = data; + } catch (e) { + error = e instanceof Error ? e : new Error('Unknown error occurred'); + current = null; + console.error('Query error:', e); + } finally { + loading = false; + } + } + + // Auto-fetch on creation (browser only for client-side queries) + if (browser) { + fetchData(); + } + + return { + get current() { + return current; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + refresh: fetchData + }; +} + +/** + * Create a parameterized query that automatically refetches when parameters change + * Works in both SSR and CSR contexts, but only auto-fetches in browser + * + * @param param - Parameter value that the query depends on + * @param fetcher - Async function that fetches data using the parameter + * @returns Query object with current data, loading state, error state, and refresh function + * + * @example + * ```typescript + * const taskId = $derived(Number(page.params.taskId)); + * + * const taskQuery = createParameterizedQuery( + * taskId, + * async (id) => { + * const service = getClientTaskInstance(); + * if (!service) throw new Error('Service unavailable'); + * const result = await service.getTaskById(id); + * if (!result.success) throw new Error(result.error || 'Failed to fetch'); + * return result.data!; + * } + * ); + * ``` + */ +export function createParameterizedQuery( + param: () => P, + fetcher: (param: P) => Promise +): Query { + let current = $state(null); + let loading = $state(true); + let error = $state(null); + + // Watch parameter changes and refetch automatically + // This properly tracks reactive parameters like $derived values + $effect(() => { + // Access param to establish reactivity + if (!browser) return; + + loading = true; + error = null; + + fetcher(param()) + .then((data) => { + current = data; + loading = false; + }) + .catch((e) => { + error = e instanceof Error ? e : new Error('Unknown error occurred'); + current = null; + loading = false; + console.error('Parameterized query error:', e); + }); + }); + + // Manual refresh function that uses current param value + async function refresh(): Promise { + if (!browser) return; + + loading = true; + error = null; + + try { + const data = await fetcher(param()); + current = data; + } catch (e) { + error = e instanceof Error ? e : new Error('Unknown error occurred'); + current = null; + console.error('Parameterized query error:', e); + } finally { + loading = false; + } + } + + return { + get current() { + return current; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + refresh + }; +} 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" - > - - {m.login_email_label()} - - {#each login.fields.email.issues() as issue} - {issue.message} - {/each} - - - - - {m.login_password_label()} - - {m.login_forgot_password()} - - - - {#each login.fields._password.issues() as issue} - {issue.message} - {/each} - - - - {m.login_submit()} - - + diff --git a/src/routes/(landing)/login/login.remote.ts b/src/routes/(landing)/login/login.remote.ts deleted file mode 100644 index abafbef..0000000 --- a/src/routes/(landing)/login/login.remote.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as v from 'valibot'; -import { error, isHttpError, redirect } from '@sveltejs/kit'; -import { form, getRequestEvent } from '$app/server'; -import { AuthService } from '$lib/services/AuthService'; -import { createApiClient } from '$lib/services/ApiService'; -import { TokenManager } from '$lib/token'; -import { localizeUrl } from '$lib/paraglide/runtime'; -import { AppRoutes } from '$lib/routes'; -import * as m from '$lib/paraglide/messages'; - -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()) - ) -}); - -type LoginData = v.InferOutput; - -export const login = form(LoginSchema, async (data: LoginData) => { - const event = getRequestEvent(); - const redirectToParam = event.url.searchParams.get('redirectTo'); - - const targetPath = redirectToParam || AppRoutes.Dashboard; - const localizedUrl = localizeUrl(new URL(targetPath, event.url.origin)); - const redirectTo = localizedUrl.pathname; - - const apiClient = createApiClient(event.cookies); - const authService = new AuthService(apiClient); - - try { - const result = await authService.login({ - email: data.email, - password: data._password - }); - if (!result.success) { - error(result.status, { message: result.error || m.error_default_message() }); - } - - if (result.data) { - TokenManager.setAccessToken(event.cookies, result.data); - } - } catch (err) { - if (isHttpError(err)) { - error(err.status, { message: err.body.message }); - } else { - const errorMessage = err instanceof Error ? err.message : m.error_default_message(); - error(500, { message: errorMessage }); - } - } - redirect(303, redirectTo); -}); 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" - > - - {m.register_email_label()} - - {#each register.fields.email.issues() as issue} - {issue.message} - {/each} - - - - {m.register_name_label()} - - {#each register.fields.name.issues() as issue} - {issue.message} - {/each} - - - - {m.register_surname_label()} - - {#each register.fields.surname.issues() as issue} - {issue.message} - {/each} - - - - {m.register_username_label()} - - {#each register.fields.username.issues() as issue} - {issue.message} - {/each} - - - - {m.register_password_label()} - - {#each register.fields._password.issues() as issue} - {issue.message} - {/each} - - - - {m.register_confirm_password_label()} - - {#each register.fields._confirmPassword.issues() as issue} - {issue.message} - {/each} - - - - {m.register_submit()} - - + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 720a27a..6a85552 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,8 +2,29 @@ import '../app.css'; import { Toaster } from '$lib/components/ui/sonner/index.js'; import favicon from '$lib/assets/favicon.svg'; + import { onMount } from 'svelte'; + import { browser } from '$app/environment'; + import { page } from '$app/state'; + import { getApiInstance } from '$lib/services'; + import { isProtectedRoute } from '$lib/routes'; let { children } = $props(); + + onMount(async () => { + if (browser) { + // Only perform silent refresh on protected routes + if (isProtectedRoute(page.url.pathname)) { + const apiClient = getApiInstance(); + if (apiClient) { + try { + await apiClient.silentRefresh(); + } catch (error) { + console.debug('Silent refresh not available:', error); + } + } + } + } + }); diff --git a/src/routes/dashboard/+layout.server.ts b/src/routes/dashboard/+layout.server.ts deleted file mode 100644 index 696ae55..0000000 --- a/src/routes/dashboard/+layout.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const load = async ({ locals }: { locals: App.Locals }) => { - return { - user: locals.user! - }; -}; diff --git a/src/routes/dashboard/+layout.svelte b/src/routes/dashboard/+layout.svelte index 2b92bdf..1b49eae 100644 --- a/src/routes/dashboard/+layout.svelte +++ b/src/routes/dashboard/+layout.svelte @@ -3,24 +3,77 @@ import DashboardSidebar from '$lib/components/dashboard/DashboardSidebar.svelte'; import { getDashboardTitleTranslationFromPathname } from '$lib/components/dashboard/utils'; import { page } from '$app/state'; - import type { LayoutProps } from './$types'; + import { onMount } from 'svelte'; + import { browser } from '$app/environment'; + import { goto } from '$app/navigation'; + import { AppRoutes } from '$lib/routes'; + import { userStore } from '$lib/stores/user-store.svelte'; + import { tokenStore } from '$lib/stores/token-store.svelte'; + import { getUserInstance, getApiInstance } from '$lib/services'; + import { LoadingSpinner } from '$lib/components/common'; import Footer from '$lib/components/Footer.svelte'; - let { children, data }: LayoutProps = $props(); + let { children } = $props(); const pageTitle = $derived(getDashboardTitleTranslationFromPathname(page.url.pathname)); + const isLoading = $derived(userStore.isLoading()); + + // Client-side authentication guard and user fetch + onMount(async () => { + if (!browser) return; + + // Check if we have a token, if not try silent refresh + if (!tokenStore.hasToken()) { + const apiClient = getApiInstance(); + if (apiClient) { + try { + const refreshed = await apiClient.silentRefresh(); + if (!refreshed) { + // No valid session, redirect to login + const redirectTo = page.url.pathname + page.url.search; + goto(`${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + return; + } + } catch (error) { + // Silent refresh failed, redirect to login + const redirectTo = page.url.pathname + page.url.search; + goto(`${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + return; + } + } + } + + // Fetch user data if not already loaded + if (!userStore.tryGetUser() && !isLoading) { + const userService = getUserInstance(); + if (userService) { + const result = await userService.getCurrentUser(); + if (!result.success) { + // Failed to fetch user, redirect to login + const redirectTo = page.url.pathname + page.url.search; + goto(`${AppRoutes.Login}?redirectTo=${encodeURIComponent(redirectTo)}`); + } + } + } + }); - - - - - - {pageTitle} - - - {@render children()} - - - - +{#if isLoading || !userStore.tryGetUser()} + + + +{:else} + + + + + + {pageTitle} + + + {@render children()} + + + + +{/if} diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index ef34ff8..9bb5b33 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -1,11 +1,11 @@ -{#if userQuery.error} - userQuery.refresh()} - /> -{:else if userQuery.loading} - -{:else if userQuery.current} - - - - - 👋 {m.dashboard_welcome()}, {userQuery.current.name}! - - {dateString} - + + + + + 👋 {m.dashboard_welcome()}, {user.name}! + + {dateString} + - - {m.dashboard_quick_actions()} - - + + {m.dashboard_quick_actions()} + -{/if} + diff --git a/src/routes/dashboard/admin/+page.svelte b/src/routes/dashboard/admin/+page.svelte index 5fe4ab0..a2414af 100644 --- a/src/routes/dashboard/admin/+page.svelte +++ b/src/routes/dashboard/admin/+page.svelte @@ -1,5 +1,6 @@ + const taskService = getTaskInstance(); - - - - - {m.tasks_page_title()} - - - {m.tasks_page_description()} - - + const tasksQuery = createQuery(async () => { + if (!taskService) throw new Error('Task service not available'); + const result = await taskService.getAllTasks(); + if (!result.success) throw new Error(result.error || 'Failed to fetch tasks'); + return result.data!; + }); + - - - {m.tasks_all_tasks()} + + {m.tasks_page_title()} + - {#if tasksQuery.error} - tasksQuery.refresh()} - inCard - /> - {:else if tasksQuery.loading} - - {:else if tasksQuery.current && tasksQuery.current.length === 0} - - {:else if tasksQuery.current} - - {/if} + + + {m.tasks_title()} + {m.tasks_subtitle()} + + {#if tasksQuery.error} + tasksQuery.refresh()} + /> + {:else if tasksQuery.loading} + + {:else if tasksQuery.current && tasksQuery.current.length === 0} + + {:else if tasksQuery.current} + + {#each tasksQuery.current as task (task.id)} + + {/each} + + {/if} diff --git a/src/routes/dashboard/tasks/[taskId]/+page.svelte b/src/routes/dashboard/tasks/[taskId]/+page.svelte index 0b09489..98e6870 100644 --- a/src/routes/dashboard/tasks/[taskId]/+page.svelte +++ b/src/routes/dashboard/tasks/[taskId]/+page.svelte @@ -1,6 +1,7 @@ @@ -49,8 +61,8 @@ languages={languagesQuery.current || []} loading={languagesQuery.loading} error={languagesQuery.error} - submitAction={submitSolution} - taskId={data.taskId} + {taskId} + onSuccess={() => taskQuery.refresh()} /> diff --git a/src/routes/dashboard/tasks/[taskId]/submit.remote.ts b/src/routes/dashboard/tasks/[taskId]/submit.remote.ts deleted file mode 100644 index 38ccd13..0000000 --- a/src/routes/dashboard/tasks/[taskId]/submit.remote.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as v from 'valibot'; -import { form, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { SubmissionService } from '$lib/services/SubmissionService'; -import { error } from '@sveltejs/kit'; -import { getTask } from './task.remote'; - -const SubmitSolutionSchema = v.object({ - taskId: v.pipe(v.number(), v.minValue(1)), - solution: v.instance(File, 'Solution file is required'), - languageId: v.pipe(v.number(), v.minValue(1)) -}); - -type SubmitSolutionData = v.InferOutput; - -export const submitSolution = form(SubmitSolutionSchema, async (data: SubmitSolutionData) => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const submissionService = new SubmissionService(apiClient); - - const result = await submissionService.submitSolution({ - taskID: data.taskId, - solution: data.solution, - languageID: data.languageId - }); - - if (!result.success) { - error(result.status, { message: result.error || 'Failed to submit solution.' }); - } - - await getTask(data.taskId).refresh(); - - return { success: true }; -}); - -export type SubmitSolutionRemoteForm = typeof submitSolution; diff --git a/src/routes/dashboard/tasks/[taskId]/task.remote.ts b/src/routes/dashboard/tasks/[taskId]/task.remote.ts deleted file mode 100644 index 5931919..0000000 --- a/src/routes/dashboard/tasks/[taskId]/task.remote.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as v from 'valibot'; -import { query, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { TaskService } from '$lib/services/TaskService'; -import { SubmissionService } from '$lib/services/SubmissionService'; -import { error } from '@sveltejs/kit'; - -export const getTask = query(v.number(), async (id: number) => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const taskService = new TaskService(apiClient); - - const result = await taskService.getTaskById(id); - if (!result.success || !result.data) { - error(result.status, { message: result.error || 'Failed to fetch task.' }); - } - - const task = result.data; - // Fetch PDF description if available - let pdfDataUrl: string | null = null; - if (task.descriptionUrl) { - try { - const response = await fetch(task.descriptionUrl); - - if (response.ok) { - const blob = await response.blob(); - const arrayBuffer = await blob.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - const base64 = Buffer.from(uint8Array).toString('base64'); - pdfDataUrl = `data:application/pdf;base64,${base64}`; - } - } catch (err) { - // If PDF fetch fails, continue without it - console.error('Failed to fetch PDF:', err); - } - } - - return { - ...task, - pdfDataUrl - }; -}); - -export const getLanguages = query(async () => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const submissionService = new SubmissionService(apiClient); - - const result = await submissionService.getAvailableLanguages(); - if (!result.success) { - error(result.status, { message: result.error || 'Failed to fetch languages.' }); - } - - return result.data || []; -}); diff --git a/src/routes/dashboard/tasks/tasks.remote.ts b/src/routes/dashboard/tasks/tasks.remote.ts deleted file mode 100644 index 32f87b1..0000000 --- a/src/routes/dashboard/tasks/tasks.remote.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { query, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { TaskService } from '$lib/services/TaskService'; -import { error } from '@sveltejs/kit'; -import type { Task } from '$lib/dto/task'; - -export const getTasks = query(async (): Promise => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const taskService = new TaskService(apiClient); - - const result = await taskService.getAllTasks(); - if (!result.success || !result.data) { - error(result.status, { message: result.error || 'Failed to fetch tasks.' }); - } - - return result.data; -}); diff --git a/src/routes/dashboard/teacher/contests/+page.svelte b/src/routes/dashboard/teacher/contests/+page.svelte index 7b75435..4b793e5 100644 --- a/src/routes/dashboard/teacher/contests/+page.svelte +++ b/src/routes/dashboard/teacher/contests/+page.svelte @@ -1,11 +1,17 @@ @@ -17,7 +23,7 @@ {m.admin_contests_quick_actions()} - + contestsQuery.refresh()} /> diff --git a/src/routes/dashboard/teacher/contests/[contestId]/+layout.server.ts b/src/routes/dashboard/teacher/contests/[contestId]/+layout.server.ts index 25aa502..1af8aa3 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/+layout.server.ts +++ b/src/routes/dashboard/teacher/contests/[contestId]/+layout.server.ts @@ -1,15 +1,8 @@ import { error } from '@sveltejs/kit'; -import { createApiClient } from '$lib/services/ApiService'; -import { AccessControlService } from '$lib/services/AccessControlService'; -import { ResourceType } from '$lib/dto/accessControl'; - export const load = async ({ - params, - parent, - cookies + params }: { params: { contestId: string }; - parent: () => Promise<{ user: { userId: number; role: string } }>; cookies: import('@sveltejs/kit').Cookies; }) => { const contestId = parseInt(params.contestId, 10); @@ -18,23 +11,7 @@ export const load = async ({ throw error(400, 'Invalid contest ID'); } - const parentData = await parent(); - - // Verify contest exists by checking if we can access collaborators - const apiClient = createApiClient(cookies); - const accessControlService = new AccessControlService(apiClient); - const result = await accessControlService.getCollaborators(ResourceType.Contests, contestId); - - if (!result.success) { - if (result.status === 404) { - throw error(404, 'Contest not found'); - } - // For other errors (like 403 forbidden), we still allow access to the layout - // The collaborators page will handle the error display - } - return { - contestId, - currentUserId: parentData.user.userId + contestId }; }; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/+page.svelte index 3fa611b..e66adec 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/+page.svelte +++ b/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/+page.svelte @@ -1,5 +1,4 @@ @@ -172,33 +207,7 @@ - { - try { - await submit(); - toast.success(m.admin_contest_tasks_add_success()); - dialogOpen = false; - selectedTaskId = null; - } catch (error: unknown) { - if (isHttpError(error)) { - toast.error(error.body.message); - } else { - toast.error(m.admin_contest_tasks_add_error()); - } - } - })} - class="space-y-6" - > - - - - - - + @@ -243,10 +252,6 @@ class="scheme-light transition-all duration-200 focus:ring-2 focus:ring-primary dark:scheme-dark" /> - - {#each addTaskToContest.fields.startAt.issues() as issue (issue.message)} - {issue.message} - {/each} @@ -298,10 +303,6 @@ class="scheme-light transition-all duration-200 focus:ring-2 focus:ring-primary dark:scheme-dark" /> - - {#each addTaskToContest.fields.endAt.issues() as issue (issue.message)} - {issue.message} - {/each} {/if} @@ -319,6 +320,7 @@ {m.admin_contest_tasks_add_button()} diff --git a/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/tasks.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/tasks.remote.ts deleted file mode 100644 index 618c868..0000000 --- a/src/routes/dashboard/teacher/contests/[contestId]/assignable-tasks/tasks.remote.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { query, form, getRequestEvent } from '$app/server'; -import { createContestsManagementService } from '$lib/services/ContestsManagementService'; -import { ApiError } from '$lib/services/ApiService'; -import type { Task } from '$lib/dto/task'; -import { error } from '@sveltejs/kit'; -import * as v from 'valibot'; - -export const getAssignableTasks = query(v.number(), async (contestId): Promise => { - const { cookies } = getRequestEvent(); - - try { - const contestsManagementService = createContestsManagementService(cookies); - const tasks = await contestsManagementService.getAssignableTasks(contestId); - - return tasks; - } catch (err) { - console.error('Failed to load assignable tasks:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load assignable tasks'); - } -}); - -export const addTaskToContest = form( - v.object({ - contestId: v.pipe(v.number(), v.minValue(1)), - taskId: v.pipe(v.number(), v.minValue(1)), - startAt: v.pipe(v.string(), v.nonEmpty('Start date is required')), - endAt: v.optional(v.string()) - }), - async (data) => { - const { cookies } = getRequestEvent(); - - try { - const contestsManagementService = createContestsManagementService(cookies); - const contestTask = await contestsManagementService.addTaskToContest(data.contestId, { - taskId: data.taskId, - startAt: data.startAt, - endAt: data.endAt ? data.endAt : null - }); - - return { success: true, contestTask }; - } catch (err) { - console.error('Failed to add task to contest:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to add task to contest'); - } - } -); - -export type AddTaskToContestForm = typeof addTaskToContest; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/collaborators/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/collaborators/+page.svelte index cfe1040..cea31f4 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/collaborators/+page.svelte +++ b/src/routes/dashboard/teacher/contests/[contestId]/collaborators/+page.svelte @@ -1,12 +1,7 @@ @@ -32,8 +48,12 @@ {#if assignableQuery.current} { + groupsQuery.refresh(); + assignableQuery.refresh(); + }} /> {/if} @@ -65,9 +85,13 @@ {group.name} { + groupsQuery.refresh(); + assignableQuery.refresh(); + }} /> diff --git a/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts deleted file mode 100644 index ac3eab2..0000000 --- a/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { query, command, getRequestEvent } from '$app/server'; -import { createContestsManagementService } from '$lib/services/ContestsManagementService'; -import { ApiError } from '$lib/services/ApiService'; -import type { Group } from '$lib/dto/group'; -import { error } from '@sveltejs/kit'; -import * as v from 'valibot'; - -export const getContestGroups = query(v.number(), async (contestId: number): Promise => { - const { cookies } = getRequestEvent(); - - try { - const contestsService = createContestsManagementService(cookies); - return await contestsService.getContestGroups(contestId); - } catch (err) { - console.error('Failed to load contest groups:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load contest groups'); - } -}); - -export const getAssignableGroups = query( - v.number(), - async (contestId: number): Promise => { - const { cookies } = getRequestEvent(); - - try { - const contestsService = createContestsManagementService(cookies); - return await contestsService.getAssignableGroups(contestId); - } catch (err) { - console.error('Failed to load assignable groups:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load assignable groups'); - } - } -); - -export const addGroupsToContest = command( - v.object({ - contestId: v.pipe(v.number(), v.integer()), - groupIds: v.array(v.pipe(v.number(), v.integer())) - }), - async (data) => { - const { cookies } = getRequestEvent(); - - try { - const contestsService = createContestsManagementService(cookies); - await contestsService.addGroupsToContest(data.contestId, data.groupIds); - - // Refresh contest groups - await getContestGroups(data.contestId).refresh(); - - return { success: true }; - } catch (err) { - console.error('Failed to add groups to contest:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to add groups to contest'); - } - } -); - -export const removeGroupsFromContest = command( - v.object({ - contestId: v.pipe(v.number(), v.integer()), - groupIds: v.array(v.pipe(v.number(), v.integer())) - }), - async (data) => { - const { cookies } = getRequestEvent(); - - try { - const contestsService = createContestsManagementService(cookies); - await contestsService.removeGroupsFromContest(data.contestId, data.groupIds); - - // Refresh contest groups - await getContestGroups(data.contestId).refresh(); - - return { success: true }; - } catch (err) { - console.error('Failed to remove groups from contest:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to remove groups from contest'); - } - } -); diff --git a/src/routes/dashboard/teacher/contests/[contestId]/registration-requests/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/registration-requests/+page.svelte index 3745295..a27aab1 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/registration-requests/+page.svelte +++ b/src/routes/dashboard/teacher/contests/[contestId]/registration-requests/+page.svelte @@ -1,10 +1,7 @@ @@ -26,7 +36,7 @@ {m.admin_contest_tasks_page_title({ contestId: data.contestId })} @@ -92,10 +102,10 @@ {m.admin_contest_tasks_view_user_stats()} tasksQuery.refresh()} /> diff --git a/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte index 8549d12..463dc1f 100644 --- a/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte +++ b/src/routes/dashboard/teacher/contests/[contestId]/tasks/[taskId]/user-stats/+page.svelte @@ -1,6 +1,7 @@ @@ -18,7 +26,7 @@ {m.admin_contests_quick_actions()} - + groupsQuery.refresh()} /> diff --git a/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte b/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte index f8f45f1..6ae81a9 100644 --- a/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte +++ b/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte @@ -1,6 +1,7 @@ @@ -50,7 +66,7 @@ {m.admin_contests_quick_actions()} - + membersQuery.refresh()} /> membersQuery.refresh()} /> @@ -127,5 +144,9 @@ {#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 @@ diff --git a/src/routes/dashboard/user/contests/[contestId]/contest.remote.ts b/src/routes/dashboard/user/contests/[contestId]/contest.remote.ts deleted file mode 100644 index e47b9ea..0000000 --- a/src/routes/dashboard/user/contests/[contestId]/contest.remote.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { query, getRequestEvent } from '$app/server'; -import { createContestService } from '$lib/services/ContestService'; -import { ApiError } from '$lib/services/ApiService'; -import { error } from '@sveltejs/kit'; -import * as v from 'valibot'; -import type { ContestTaskWithStatistics } from '$lib/dto/task'; - -export const getContestTasksWithStatistics = query( - v.number(), - async (contestId: number): Promise => { - const { cookies } = getRequestEvent(); - - try { - const contestService = createContestService(cookies); - const tasks = await contestService.getContestTasksWithStatistics(contestId); - - return tasks; - } catch (err) { - console.error('Failed to load contest tasks with statistics:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load contest tasks'); - } - } -); diff --git a/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte b/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte index 385b516..05e13e9 100644 --- a/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte +++ b/src/routes/dashboard/user/contests/[contestId]/results/+page.svelte @@ -1,4 +1,7 @@ @@ -50,9 +60,9 @@ languages={languagesQuery.current || []} loading={languagesQuery.loading} error={languagesQuery.error} - submitAction={submitContestSolution} - contestId={data.contestId} - taskId={data.taskId} + {contestId} + {taskId} + onSuccess={() => taskQuery.refresh()} /> diff --git a/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/submit.remote.ts b/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/submit.remote.ts deleted file mode 100644 index 8a26beb..0000000 --- a/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/submit.remote.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as v from 'valibot'; -import { form, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { SubmissionService } from '$lib/services/SubmissionService'; -import { error } from '@sveltejs/kit'; -import { getContestTask } from './task.remote'; - -const SubmitContestSolutionSchema = v.object({ - contestId: v.pipe(v.number(), v.minValue(1)), - taskId: v.pipe(v.number(), v.minValue(1)), - solution: v.instance(File, 'Solution file is required'), - languageId: v.pipe(v.number(), v.minValue(1)) -}); - -type SubmitContestSolutionData = v.InferOutput; - -export const submitContestSolution = form( - SubmitContestSolutionSchema, - async (data: SubmitContestSolutionData) => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const submissionService = new SubmissionService(apiClient); - - const result = await submissionService.submitSolution({ - taskID: data.taskId, - contestID: data.contestId, - solution: data.solution, - languageID: data.languageId - }); - - if (!result.success) { - error(result.status, { message: result.error || 'Failed to submit solution.' }); - } - - getContestTask({ contestId: data.contestId, taskId: data.taskId }).refresh(); - - return { success: true }; - } -); - -export type SubmitContestSolutionRemoteForm = typeof submitContestSolution; diff --git a/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/task.remote.ts b/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/task.remote.ts deleted file mode 100644 index 32fba2f..0000000 --- a/src/routes/dashboard/user/contests/[contestId]/tasks/[taskId]/task.remote.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as v from 'valibot'; -import { query, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { createContestService } from '$lib/services/ContestService'; -import { SubmissionService } from '$lib/services/SubmissionService'; -import { error } from '@sveltejs/kit'; - -const GetContestTaskSchema = v.object({ - contestId: v.pipe(v.number(), v.minValue(1)), - taskId: v.pipe(v.number(), v.minValue(1)) -}); - -type GetContestTaskInput = v.InferOutput; - -export const getContestTask = query(GetContestTaskSchema, async (input: GetContestTaskInput) => { - const event = getRequestEvent(); - const contestService = createContestService(event.cookies); - - const task = await contestService.getContestTask(input.contestId, input.taskId); - - // Fetch PDF description if available - let pdfDataUrl: string | null = null; - if (task.descriptionUrl) { - try { - const response = await fetch(task.descriptionUrl); - - if (response.ok) { - const blob = await response.blob(); - const arrayBuffer = await blob.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); - pdfDataUrl = `data:application/pdf;base64,${base64}`; - } - } catch (err) { - // If PDF fetch fails, continue without it - console.error('Failed to fetch PDF:', err); - } - } - - return { - ...task, - pdfDataUrl - }; -}); - -export const getLanguages = query(async () => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const submissionService = new SubmissionService(apiClient); - - const result = await submissionService.getAvailableLanguages(); - if (!result.success) { - error(result.status, { message: result.error || 'Failed to fetch languages.' }); - } - - return result.data || []; -}); diff --git a/src/routes/dashboard/user/contests/contests.remote.ts b/src/routes/dashboard/user/contests/contests.remote.ts deleted file mode 100644 index 936f9a2..0000000 --- a/src/routes/dashboard/user/contests/contests.remote.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { query, getRequestEvent } from '$app/server'; -import { createContestService } from '$lib/services/ContestService'; -import { ApiError } from '$lib/services/ApiService'; -import { error } from '@sveltejs/kit'; -import type { ContestWithStats, PastContestWithStats } from '$lib/dto/contest'; - -export interface UserContestsData { - active: ContestWithStats[]; - past: PastContestWithStats[]; -} - -export const getUserContests = query(async (): Promise => { - const { cookies } = getRequestEvent(); - - try { - const contestService = createContestService(cookies); - - const [active, past] = await Promise.all([ - contestService.getMyActiveContests(), - contestService.getMyPastContests() - ]); - - return { - active, - past - }; - } catch (err) { - console.error('Failed to load user contests:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load contests'); - } -}); diff --git a/src/routes/dashboard/user/profile/+page.svelte b/src/routes/dashboard/user/profile/+page.svelte index d8c878c..3e82184 100644 --- a/src/routes/dashboard/user/profile/+page.svelte +++ b/src/routes/dashboard/user/profile/+page.svelte @@ -3,9 +3,16 @@ import QuickActions from '$lib/components/dashboard/profile/QuickActions.svelte'; import { LoadingSpinner, ErrorCard } from '$lib/components/common'; import * as m from '$lib/paraglide/messages'; - import { getUserProfile } from './profile.remote'; + import { createQuery } from '$lib/utils/query.svelte'; + import { getUserInstance } from '$lib/services'; - const userProfileQuery = getUserProfile(); + const userService = getUserInstance(); + const userProfileQuery = createQuery(async () => { + if (!userService) throw new Error('Service unavailable'); + const result = await userService.getCurrentUser(); + if (!result.success) throw new Error(result.error || 'Failed to fetch profile'); + return result.data!; + }); diff --git a/src/routes/dashboard/user/profile/password.remote.ts b/src/routes/dashboard/user/profile/password.remote.ts deleted file mode 100644 index e040cba..0000000 --- a/src/routes/dashboard/user/profile/password.remote.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { form } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { UserService } from '$lib/services/UserService'; -import { getRequestEvent } from '$app/server'; -import * as v from 'valibot'; -import { error } from 'console'; - -const passwordChangeSchema = v.pipe( - v.object({ - oldPassword: v.pipe(v.string(), v.nonEmpty('Current password is required')), - newPassword: v.pipe( - v.string(), - v.minLength(8, 'Password must be at least 8 characters'), - v.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'), - v.regex(/[a-z]/, 'Password must contain at least one lowercase letter'), - v.regex(/\d/, 'Password must contain at least one digit'), - v.regex(/[^A-Za-z\d]/, 'Password must contain at least one special character') - ), - newPasswordConfirm: v.pipe(v.string(), v.nonEmpty('Please confirm your new password')) - }), - v.forward( - v.partialCheck( - [['newPassword'], ['newPasswordConfirm']], - (input) => input.newPassword === input.newPasswordConfirm, - 'Passwords do not match' - ), - ['newPasswordConfirm'] - ) -); - -export const changePassword = form(passwordChangeSchema, async (data) => { - const { cookies } = getRequestEvent(); - const apiClient = createApiClient(cookies); - const userService = new UserService(apiClient); - - // Get current user to get their ID - const userResult = await userService.getCurrentUser(); - if (!userResult.success || !userResult.data) { - error(userResult.status, { message: userResult.error || 'Failed to get current user.' }); - } - - // Change password - const passwordResult = await userService.changePassword(userResult.data!.id, data); - if (!passwordResult.success) { - error(passwordResult.status, { message: passwordResult.error || 'Failed to change password' }); - } - - return { success: true }; -}); diff --git a/src/routes/dashboard/user/profile/profile.remote.ts b/src/routes/dashboard/user/profile/profile.remote.ts deleted file mode 100644 index 5ff318c..0000000 --- a/src/routes/dashboard/user/profile/profile.remote.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { query, getRequestEvent } from '$app/server'; -import { createApiClient } from '$lib/services/ApiService'; -import { UserService } from '$lib/services/UserService'; -import { error } from '@sveltejs/kit'; -import type { User } from '$lib/dto/user'; - -export const getUserProfile = query(async (): Promise => { - const event = getRequestEvent(); - const apiClient = createApiClient(event.cookies); - const userService = new UserService(apiClient); - - const result = await userService.getCurrentUser(); - if (!result.success || !result.data) { - error(result.status, { message: result.error || 'Failed to fetch user profile.' }); - } - - return result.data; -}); diff --git a/src/routes/dashboard/user/submissions/+page.svelte b/src/routes/dashboard/user/submissions/+page.svelte index 9dcf7ca..de66211 100644 --- a/src/routes/dashboard/user/submissions/+page.svelte +++ b/src/routes/dashboard/user/submissions/+page.svelte @@ -1,11 +1,18 @@ diff --git a/src/routes/dashboard/user/submissions/[submissionId]/+page.svelte b/src/routes/dashboard/user/submissions/[submissionId]/+page.svelte index 80d4d6b..c8162d3 100644 --- a/src/routes/dashboard/user/submissions/[submissionId]/+page.svelte +++ b/src/routes/dashboard/user/submissions/[submissionId]/+page.svelte @@ -1,11 +1,13 @@ console.error('User tasks page error:', error)}> diff --git a/src/routes/dashboard/user/tasks/tasks.remote.ts b/src/routes/dashboard/user/tasks/tasks.remote.ts deleted file mode 100644 index 7c36db4..0000000 --- a/src/routes/dashboard/user/tasks/tasks.remote.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { query, getRequestEvent } from '$app/server'; -import { createTaskService } from '$lib/services/TaskService'; -import { ApiError } from '$lib/services/ApiService'; -import { error } from '@sveltejs/kit'; -import type { MyTasksResponse } from '$lib/dto/task'; - -export const getMyTasks = query(async (): Promise => { - const { cookies } = getRequestEvent(); - - try { - const taskService = createTaskService(cookies); - const result = await taskService.getMyTasks({ limit: 100, offset: 0 }); - - if (!result.success || !result.data) { - throw error(result.status, result.error || 'Failed to fetch tasks'); - } - - return result.data; - } catch (err) { - console.error('Failed to load user tasks:', err); - - if (err instanceof ApiError) { - throw error(err.getStatus(), err.getApiMessage()); - } - - throw error(500, 'Failed to load tasks'); - } -}); diff --git a/svelte.config.js b/svelte.config.js index c61936c..0d6a9bb 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -10,9 +10,6 @@ const config = { adapter: adapter(), alias: { $routes: './src/routes' - }, - experimental: { - remoteFunctions: true } }, compilerOptions: {
{$errors.email}
{$errors.password}
{$errors.name}
{$errors.surname}
{$errors.username}
{$errors.confirmPassword}
{issue.message}
{$errors.description}
{$errors.startAt}
{$errors.endAt}
{m.group_members_users_load_error()}
{m.groups_loading()}
{m.group_members_no_users_found()}
{m.admin_tasks_no_test_cases()}
{$errors.title}
{$errors.archive}
- {m.admin_tasks_form_selected_file({ - filename: selectedFile.name, - size: (selectedFile.size / 1024 / 1024).toFixed(2) + {m.task_file_selected({ + filename: $form.archive.name, + size: ($form.archive.size / 1024).toFixed(2) })}
- {m.admin_tasks_form_visible_description()} -
{$errors.role}
- {getFirstError(changePassword.fields.oldPassword.issues())?.message} + {getFirstError($errors.oldPassword)}
- {getFirstError(changePassword.fields.newPassword.issues())?.message} + {getFirstError($errors.newPassword)}
- {getFirstError(changePassword.fields.newPasswordConfirm.issues())?.message} + {getFirstError($errors.newPasswordConfirm)}
{dateString}
- {m.tasks_page_description()} -
{m.tasks_subtitle()}