From ae114ab9ce0eb15dc1d437d2bba0a378e9f36b12 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 22:44:31 +0300 Subject: [PATCH 1/7] chore: add plan files for Phase 2 enhanced features --- plans/phase-2-enhanced-features/CONTEXT.md | 21 ++++++ plans/phase-2-enhanced-features/PLAN.md | 44 +++++++++++++ .../phase-1-oauth.md | 58 +++++++++++++++++ .../phase-2-enhanced-features/phase-2-dnd.md | 55 ++++++++++++++++ .../phase-3-localization.md | 61 ++++++++++++++++++ .../phase-4-widgets.md | 64 +++++++++++++++++++ .../phase-5-access-control.md | 51 +++++++++++++++ .../phase-6-integration.md | 53 +++++++++++++++ 8 files changed, 407 insertions(+) create mode 100644 plans/phase-2-enhanced-features/CONTEXT.md create mode 100644 plans/phase-2-enhanced-features/PLAN.md create mode 100644 plans/phase-2-enhanced-features/phase-1-oauth.md create mode 100644 plans/phase-2-enhanced-features/phase-2-dnd.md create mode 100644 plans/phase-2-enhanced-features/phase-3-localization.md create mode 100644 plans/phase-2-enhanced-features/phase-4-widgets.md create mode 100644 plans/phase-2-enhanced-features/phase-5-access-control.md create mode 100644 plans/phase-2-enhanced-features/phase-6-integration.md diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md new file mode 100644 index 0000000..f5cd083 --- /dev/null +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -0,0 +1,21 @@ +# Feature Context: Phase 2 — Enhanced Features + +## Current State +MVP is complete and merged to master. All build/test/lint passes. 151 files, 115 tests. +Starting Phase 2 enhanced features on a new feature branch. + +## Temporary Workarounds +- None yet + +## Cross-Phase Dependencies +- Phase 1 (OAuth) is independent — touches auth system only +- Phase 2 (DnD) is independent — touches board editor UI only +- Phase 3 (Widgets) depends on existing widget system from MVP +- Phase 4 (Access Control) depends on existing permission system from MVP +- Phase 5 (Integration) depends on all prior phases + +## Implementation Notes +- Big Bang strategy: intermediate phases may not build. Phase 5 is the convergence phase. +- OAuth uses `openid-client` (already installed in MVP dependencies) +- DnD uses `svelte-dnd-action` (needs to be installed) +- New widget types extend the existing Widget model's `type` and `config` JSON fields diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md new file mode 100644 index 0000000..27f6a0e --- /dev/null +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -0,0 +1,44 @@ +# Feature: Phase 2 — Enhanced Features + +**Branch:** `feature/phase-2-enhanced-features` +**Base branch:** `master` +**Created:** 2026-03-24 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Orchestrator + +## Summary +Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), additional widget types (bookmark, note, embed, status), and per-board access control UI. + +## Build & Test Commands +- **Build:** `npm run build` +- **Test:** `npm test` +- **Lint:** `npm run lint` +- **Type Check:** `npm run check` + +## Phases + +- [ ] Phase 1: OAuth/Authentik Integration [fullstack] → [subplan](./phase-1-oauth.md) +- [ ] Phase 2: Drag-and-Drop Reordering [frontend] → [subplan](./phase-2-dnd.md) +- [ ] Phase 3: Localization EN/RU [fullstack] → [subplan](./phase-3-localization.md) +- [ ] Phase 4: Additional Widget Types [fullstack] → [subplan](./phase-4-widgets.md) +- [ ] Phase 5: Per-Board Access Control UI [fullstack] → [subplan](./phase-5-access-control.md) +- [ ] Phase 6: Integration & Polish [fullstack] → [subplan](./phase-6-integration.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: OAuth | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: DnD | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 6: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `master` diff --git a/plans/phase-2-enhanced-features/phase-1-oauth.md b/plans/phase-2-enhanced-features/phase-1-oauth.md new file mode 100644 index 0000000..63c7927 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-1-oauth.md @@ -0,0 +1,58 @@ +# Phase 1: OAuth/Authentik Integration + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows, auto-provisioning users, and admin configuration UI. + +## Tasks + +- [ ] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange +- [ ] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE +- [ ] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user +- [ ] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning +- [ ] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH +- [ ] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings +- [ ] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL) +- [ ] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button +- [ ] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions +- [ ] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI + +## Files to Modify/Create +- `src/lib/server/services/oauthService.ts` — NEW +- `src/routes/auth/oauth/authorize/+server.ts` — NEW +- `src/routes/auth/oauth/callback/+server.ts` — NEW +- `src/lib/server/services/userService.ts` — MODIFY +- `src/routes/login/+page.svelte` — MODIFY +- `src/routes/login/+page.server.ts` — MODIFY +- `src/routes/admin/settings/+page.svelte` — MODIFY +- `src/lib/components/admin/SettingsForm.svelte` — MODIFY +- `src/hooks.server.ts` — MODIFY +- `.env.example` — MODIFY + +## Acceptance Criteria +- OAuth login redirects to Authentik and returns with valid session +- New OAuth users are auto-provisioned with correct role/groups +- Existing users can link OAuth identity +- Admin can configure OAuth provider in settings +- Auth mode selector (local/oauth/both) controls which login options appear +- Login page shows appropriate buttons based on auth mode + +## Notes +- Use `openid-client` for OIDC discovery and token exchange +- Store OAuth state/nonce in HTTP-only cookies for CSRF protection +- Map Authentik groups to local groups by name +- OAuth users have nullable password field +- ⚠️ Big Bang: may not fully work until Phase 5 integration + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/phase-2-enhanced-features/phase-2-dnd.md b/plans/phase-2-enhanced-features/phase-2-dnd.md new file mode 100644 index 0000000..07186b3 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-2-dnd.md @@ -0,0 +1,55 @@ +# Phase 2: Drag-and-Drop Reordering + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Add drag-and-drop reordering for sections within boards and widgets within/across sections using svelte-dnd-action. + +## Tasks + +- [ ] Task 1: Install `svelte-dnd-action` package +- [ ] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections +- [ ] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets +- [ ] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper +- [ ] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor +- [ ] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes +- [ ] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes +- [ ] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions +- [ ] Task 9: Add visual drag handles and drop zone indicators +- [ ] Task 10: Support moving widgets between sections via cross-section DnD + +## Files to Modify/Create +- `package.json` — add svelte-dnd-action +- `src/lib/components/board/DraggableBoard.svelte` — NEW +- `src/lib/components/section/DraggableSection.svelte` — NEW +- `src/lib/components/widget/DraggableWidget.svelte` — NEW +- `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFY +- `src/routes/api/boards/[id]/reorder/+server.ts` — NEW +- `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — NEW +- `src/lib/server/services/boardService.ts` — MODIFY + +## Acceptance Criteria +- Sections can be reordered via drag-and-drop in the board editor +- Widgets can be reordered within a section +- Widgets can be moved between sections +- Order changes persist via API calls +- Drag handles are visible and accessible +- Drop zones are visually indicated during drag + +## Notes +- `svelte-dnd-action` works well with Svelte 5 +- Use optimistic updates — reorder in UI immediately, sync to server in background +- Reorder APIs should accept an array of IDs in the new order +- ⚠️ Big Bang: may need integration fixes in Phase 5 + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/phase-2-enhanced-features/phase-3-localization.md b/plans/phase-2-enhanced-features/phase-3-localization.md new file mode 100644 index 0000000..c739389 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-3-localization.md @@ -0,0 +1,61 @@ +# Phase 3: Localization (EN/RU) + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Add internationalization (i18n) support with English and Russian locales. All UI strings should be translatable. Users can switch language in settings or header. + +## Tasks + +- [ ] Task 1: Install `paraglide-sveltekit` (or `svelte-i18n`) — choose the best Svelte 5 compatible i18n library +- [ ] Task 2: Create locale files: `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` +- [ ] Task 3: Create `src/lib/i18n/index.ts` — i18n setup, locale detection, switcher +- [ ] Task 4: Create `src/lib/components/layout/LanguageSwitcher.svelte` — language toggle (EN/RU) in header +- [ ] Task 5: Extract all hardcoded strings from layout components (Sidebar, Header, MainLayout) +- [ ] Task 6: Extract all hardcoded strings from auth pages (login, register, logout) +- [ ] Task 7: Extract all hardcoded strings from board/section/widget components +- [ ] Task 8: Extract all hardcoded strings from app components (AppCard, AppForm, AppIconPicker, etc.) +- [ ] Task 9: Extract all hardcoded strings from admin pages (users, groups, settings) +- [ ] Task 10: Extract all hardcoded strings from search components +- [ ] Task 11: Add locale preference to user settings (stored in localStorage + optional DB field) +- [ ] Task 12: Add language setting to admin SystemSettings (default locale) +- [ ] Task 13: Translate all strings to Russian + +## Files to Modify/Create +- `src/lib/i18n/en.json` — NEW +- `src/lib/i18n/ru.json` — NEW +- `src/lib/i18n/index.ts` — NEW +- `src/lib/components/layout/LanguageSwitcher.svelte` — NEW +- `src/lib/components/layout/Header.svelte` — MODIFY +- `src/routes/login/+page.svelte` — MODIFY +- `src/routes/register/+page.svelte` — MODIFY +- `src/routes/boards/*.svelte` — MODIFY +- `src/routes/apps/+page.svelte` — MODIFY +- `src/routes/admin/**/*.svelte` — MODIFY +- `src/lib/components/**/*.svelte` — MODIFY (all UI components) + +## Acceptance Criteria +- All user-visible strings are translatable (no hardcoded text in components) +- English and Russian translations are complete +- Language switcher in the header toggles between EN/RU +- Locale preference persists across sessions (localStorage) +- Default locale is configurable in admin settings +- Date/number formatting respects locale + +## Notes +- Use a flat key structure: `{ "nav.boards": "Boards", "nav.apps": "Apps", ... }` +- Keep translation keys semantic and grouped by feature +- Validation error messages from Zod should also be translatable +- ⚠️ Big Bang: string extraction touches many files + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/phase-2-enhanced-features/phase-4-widgets.md b/plans/phase-2-enhanced-features/phase-4-widgets.md new file mode 100644 index 0000000..a09f234 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-4-widgets.md @@ -0,0 +1,64 @@ +# Phase 3: Additional Widget Types + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Add four new widget types: Bookmark, Note, Embed, and Status. Extend the widget system with type-specific rendering and configuration. + +## Tasks + +- [ ] Task 1: Update `src/lib/utils/constants.ts` — ensure WidgetType enum has BOOKMARK, NOTE, EMBED, STATUS +- [ ] Task 2: Update `src/lib/utils/validators.ts` — add Zod schemas for each widget type's config +- [ ] Task 3: Create `src/lib/components/widget/BookmarkWidget.svelte` — URL + label + optional icon, no healthcheck +- [ ] Task 4: Create `src/lib/components/widget/NoteWidget.svelte` — markdown/rich text display with edit mode +- [ ] Task 5: Create `src/lib/components/widget/EmbedWidget.svelte` — iframe embed with configurable URL and height +- [ ] Task 6: Create `src/lib/components/widget/StatusWidget.svelte` — aggregated status of multiple apps (green/red/yellow summary) +- [ ] Task 7: Create `src/lib/components/widget/WidgetRenderer.svelte` — universal widget renderer that switches by type +- [ ] Task 8: Update `src/lib/components/widget/WidgetGrid.svelte` — use WidgetRenderer instead of hardcoded AppWidget +- [ ] Task 9: Update board editor — add widget type selector when adding widgets +- [ ] Task 10: Update `src/routes/boards/[boardId]/edit/+page.svelte` — type-specific config forms for each widget type +- [ ] Task 11: Update `src/routes/boards/[boardId]/edit/+page.server.ts` — handle different widget types in create action +- [ ] Task 12: Install `marked` or `markdown-it` for Note widget markdown rendering + +## Files to Modify/Create +- `src/lib/utils/constants.ts` — MODIFY +- `src/lib/utils/validators.ts` — MODIFY +- `src/lib/components/widget/BookmarkWidget.svelte` — NEW +- `src/lib/components/widget/NoteWidget.svelte` — NEW +- `src/lib/components/widget/EmbedWidget.svelte` — NEW +- `src/lib/components/widget/StatusWidget.svelte` — NEW +- `src/lib/components/widget/WidgetRenderer.svelte` — NEW +- `src/lib/components/widget/WidgetGrid.svelte` — MODIFY +- `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFY +- `src/routes/boards/[boardId]/edit/+page.server.ts` — MODIFY + +## Acceptance Criteria +- All four widget types render correctly in the board view +- Each widget type has a type-specific config form in the board editor +- Bookmark: displays URL with label and optional icon, opens in new tab +- Note: renders markdown content, supports inline editing +- Embed: renders iframe with configurable URL, shows loading state +- Status: shows aggregate health of selected apps (count online/offline/total) +- WidgetRenderer correctly dispatches to the right component by type + +## Notes +- Widget config JSON structure per type: + - APP: `{ appId: string }` + - BOOKMARK: `{ url: string, label: string, icon?: string, description?: string }` + - NOTE: `{ content: string, format: 'markdown' | 'text' }` + - EMBED: `{ url: string, height: number, sandbox?: string }` + - STATUS: `{ appIds: string[], label?: string }` +- Embed widget should use sandbox attribute for security +- ⚠️ Big Bang: may need integration fixes in Phase 5 + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/phase-2-enhanced-features/phase-5-access-control.md b/plans/phase-2-enhanced-features/phase-5-access-control.md new file mode 100644 index 0000000..ce56843 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-5-access-control.md @@ -0,0 +1,51 @@ +# Phase 4: Per-Board Access Control UI + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Add a user-friendly access control interface for boards, allowing admins to manage per-board permissions with user/group pickers and visual indicators. + +## Tasks + +- [ ] Task 1: Create `src/lib/components/board/BoardAccessControl.svelte` — inline permission editor for boards +- [ ] Task 2: Add access control tab/section to board editor page +- [ ] Task 3: Create `src/routes/api/boards/[id]/permissions/+server.ts` — GET/POST/DELETE permissions for a board +- [ ] Task 4: Update `src/lib/components/admin/PermissionEditor.svelte` — enhance with user/group search/autocomplete +- [ ] Task 5: Update `src/lib/components/board/BoardCard.svelte` — show access level indicator (icon/badge) +- [ ] Task 6: Update `src/routes/boards/+page.svelte` — show access indicators on board list +- [ ] Task 7: Add guest access toggle with preview description to board editor +- [ ] Task 8: Create `src/lib/components/board/BoardShareDialog.svelte` — quick share dialog for boards + +## Files to Modify/Create +- `src/lib/components/board/BoardAccessControl.svelte` — NEW +- `src/lib/components/board/BoardShareDialog.svelte` — NEW +- `src/routes/api/boards/[id]/permissions/+server.ts` — NEW +- `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFY +- `src/routes/boards/[boardId]/edit/+page.server.ts` — MODIFY +- `src/lib/components/admin/PermissionEditor.svelte` — MODIFY +- `src/lib/components/board/BoardCard.svelte` — MODIFY +- `src/routes/boards/+page.svelte` — MODIFY + +## Acceptance Criteria +- Board editor has a permissions section for managing access +- Admins can grant/revoke view/edit/admin permissions per user or group +- Board list shows access indicators (shared icon, guest badge, etc.) +- Quick share dialog allows easy permission granting +- Guest access toggle works with visual feedback + +## Notes +- The permission system already exists from MVP (permissionService) +- This phase adds the UI layer on top of existing backend +- ⚠️ Big Bang: may need integration fixes in Phase 5 + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/phase-2-enhanced-features/phase-6-integration.md b/plans/phase-2-enhanced-features/phase-6-integration.md new file mode 100644 index 0000000..2df3d23 --- /dev/null +++ b/plans/phase-2-enhanced-features/phase-6-integration.md @@ -0,0 +1,53 @@ +# Phase 5: Integration & Polish + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Integrate all Phase 2 features, fix all build/type/lint errors, write tests, and ensure everything works together. + +## Tasks + +- [ ] Task 1: Fix all TypeScript/build errors across the codebase +- [ ] Task 2: Verify `npm run build` succeeds +- [ ] Task 3: Verify `npm run check` passes +- [ ] Task 4: Verify `npm run lint` passes +- [ ] Task 5: Write tests for oauthService +- [ ] Task 6: Write tests for new widget types (validators, rendering logic) +- [ ] Task 7: Write tests for reorder APIs +- [ ] Task 8: Write tests for board permissions API +- [ ] Task 9: Update seed script with example data for new widget types +- [ ] Task 10: Verify all existing tests still pass +- [ ] Task 11: Update `.env.example` with all new env vars documented + +## Files to Modify/Create +- Various source files — fix build errors +- New test files for Phase 2 features +- `prisma/seed.ts` — update +- `.env.example` — update + +## Acceptance Criteria +- `npm run build` succeeds +- `npm run check` passes +- `npm run lint` passes +- `npm test` passes (existing + new tests) +- All Phase 2 features work together +- OAuth flow works end-to-end (when configured) +- DnD reordering persists correctly +- All widget types render and edit correctly +- Board access control UI works with permission system + +## Notes +- Big Bang convergence — fix everything here +- Priority: build errors → type errors → lint → tests + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff + From bf4e5089ee1bacae91d9e237cf98e8a1bafed8d1 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 22:54:54 +0300 Subject: [PATCH 2/7] feat(phase2): OAuth/Authentik integration + drag-and-drop reordering - Add OIDC/OAuth2 login via openid-client with PKCE flow - Auto-provision OAuth users with group mapping - Conditional login page (OAuth/local/both based on auth mode) - Admin OAuth test connection button - Install svelte-dnd-action for board editor DnD - Draggable sections and widgets with cross-section moves - Reorder APIs with atomic Prisma transactions - Visual drag handles and drop zone indicators --- .env.example | 6 + package-lock.json | 115 +++++----- package.json | 2 + plans/phase-2-enhanced-features/CONTEXT.md | 20 +- plans/phase-2-enhanced-features/PLAN.md | 6 +- .../phase-1-oauth.md | 36 +-- .../phase-2-enhanced-features/phase-2-dnd.md | 40 ++-- src/lib/components/admin/SettingsForm.svelte | 47 +++- .../components/board/DraggableBoard.svelte | 127 +++++++++++ .../section/DraggableSection.svelte | 208 ++++++++++++++++++ .../components/widget/DraggableWidget.svelte | 41 ++++ src/lib/server/services/boardService.ts | 38 ++++ src/lib/server/services/oauthService.ts | 170 ++++++++++++++ src/lib/server/services/userService.ts | 93 ++++++++ src/routes/api/admin/oauth/test/+server.ts | 18 ++ src/routes/api/boards/[id]/reorder/+server.ts | 56 +++++ .../[id]/sections/[sid]/reorder/+server.ts | 56 +++++ src/routes/auth/oauth/authorize/+server.ts | 40 ++++ src/routes/auth/oauth/callback/+server.ts | 82 +++++++ src/routes/boards/[boardId]/edit/+page.svelte | 175 ++++++--------- src/routes/login/+page.server.ts | 12 +- src/routes/login/+page.svelte | 142 +++++++----- 22 files changed, 1273 insertions(+), 257 deletions(-) create mode 100644 src/lib/components/board/DraggableBoard.svelte create mode 100644 src/lib/components/section/DraggableSection.svelte create mode 100644 src/lib/components/widget/DraggableWidget.svelte create mode 100644 src/lib/server/services/oauthService.ts create mode 100644 src/routes/api/admin/oauth/test/+server.ts create mode 100644 src/routes/api/boards/[id]/reorder/+server.ts create mode 100644 src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts create mode 100644 src/routes/auth/oauth/authorize/+server.ts create mode 100644 src/routes/auth/oauth/callback/+server.ts diff --git a/.env.example b/.env.example index 0056c89..3bf0f40 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ APP_PORT=3000 APP_HOST="0.0.0.0" APP_URL="http://localhost:3000" +# OAuth / OIDC (optional — configure here or in Admin > Settings) +OAUTH_CLIENT_ID="" +OAUTH_CLIENT_SECRET="" +OAUTH_DISCOVERY_URL="" +OAUTH_REDIRECT_URI="" + # Guest mode (true = allow unauthenticated dashboard access) GUEST_MODE="true" diff --git a/package-lock.json b/package-lock.json index b89909e..c340b87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", "node-cron": "^3.0.3", + "openid-client": "^6.8.2", "simple-icons": "^13.0.0", "svelte": "^5.0.0", + "svelte-dnd-action": "^0.9.69", "sveltekit-superforms": "^2.22.0", "tailwind-merge": "^2.6.0", "zod": "^3.24.0" @@ -3439,6 +3441,14 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4023,12 +4033,32 @@ "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", "dev": true }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "dev": true }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4887,6 +4917,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/svelte-dnd-action": { + "version": "0.9.69", + "resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz", + "integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==", + "peerDependencies": { + "svelte": ">=3.23.0 || ^5.0.0-next.0" + } + }, "node_modules/svelte-eslint-parser": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", @@ -5249,7 +5287,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -5265,7 +5302,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -5281,7 +5317,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5297,7 +5332,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5313,7 +5347,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5329,7 +5362,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5345,7 +5377,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5361,7 +5392,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5377,7 +5407,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5393,7 +5422,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5409,7 +5437,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5425,7 +5452,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5441,7 +5467,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5457,7 +5482,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5473,7 +5497,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5489,7 +5512,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5505,7 +5527,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5521,7 +5542,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -5537,7 +5557,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -5553,7 +5572,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -5569,7 +5587,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -5585,7 +5602,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openharmony" @@ -5601,7 +5617,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -5617,7 +5632,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5633,7 +5647,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5649,7 +5662,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -8304,6 +8316,11 @@ "@sideway/pinpoint": "^2.0.0" } }, + "jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8672,12 +8689,26 @@ } } }, + "oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==" + }, "ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "dev": true }, + "openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "requires": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + } + }, "optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9188,6 +9219,12 @@ } } }, + "svelte-dnd-action": { + "version": "0.9.69", + "resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz", + "integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==", + "requires": {} + }, "svelte-eslint-parser": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", @@ -9383,182 +9420,156 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "dev": true, "optional": true }, "esbuild": { diff --git a/package.json b/package.json index 39725ed..5315f7a 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", "node-cron": "^3.0.3", + "openid-client": "^6.8.2", "simple-icons": "^13.0.0", "svelte": "^5.0.0", + "svelte-dnd-action": "^0.9.69", "sveltekit-superforms": "^2.22.0", "tailwind-merge": "^2.6.0", "zod": "^3.24.0" diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md index f5cd083..7898aa1 100644 --- a/plans/phase-2-enhanced-features/CONTEXT.md +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -1,8 +1,11 @@ # Feature Context: Phase 2 — Enhanced Features ## Current State -MVP is complete and merged to master. All build/test/lint passes. 151 files, 115 tests. -Starting Phase 2 enhanced features on a new feature branch. + +Phase 1 (OAuth/Authentik Integration) and Phase 2 (DnD) are complete. +Installed `openid-client` v6.8.2. OAuth login flow uses PKCE and issues local JWT tokens. +Login page conditionally shows OAuth button and/or local form based on `authMode` SystemSettings. +Admin settings page has a working "Test Connection" button for OAuth configuration. ## Temporary Workarounds - None yet @@ -15,7 +18,16 @@ Starting Phase 2 enhanced features on a new feature branch. - Phase 5 (Integration) depends on all prior phases ## Implementation Notes -- Big Bang strategy: intermediate phases may not build. Phase 5 is the convergence phase. +- Big Bang strategy: intermediate phases may not build. Phase 6 is the convergence phase. - OAuth uses `openid-client` (already installed in MVP dependencies) -- DnD uses `svelte-dnd-action` (needs to be installed) +- DnD uses `svelte-dnd-action` (installed in Phase 2) - New widget types extend the existing Widget model's `type` and `config` JSON fields + +## Phase 2 (DnD) — Completed +- Installed `svelte-dnd-action` package +- Created `DraggableBoard.svelte`, `DraggableSection.svelte`, `DraggableWidget.svelte` component hierarchy +- Board edit page now uses DnD for section and widget reordering (including cross-section widget moves) +- Added `PUT /api/boards/[id]/reorder` and `PUT /api/boards/[id]/sections/[sid]/reorder` endpoints +- Extended `boardService.ts` with `reorderSections()`, `reorderWidgets()`, `moveWidget()` using Prisma transactions +- Visual drag handles (grip dots) and dashed drop zone indicators added via Tailwind +- Edit page actions (add/delete section/widget) use `invalidateAll()` for data refresh; DnD uses optimistic fetch diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md index 27f6a0e..66a5d97 100644 --- a/plans/phase-2-enhanced-features/PLAN.md +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -19,7 +19,7 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), ## Phases -- [ ] Phase 1: OAuth/Authentik Integration [fullstack] → [subplan](./phase-1-oauth.md) +- [x] Phase 1: OAuth/Authentik Integration [fullstack] → [subplan](./phase-1-oauth.md) - [ ] Phase 2: Drag-and-Drop Reordering [frontend] → [subplan](./phase-2-dnd.md) - [ ] Phase 3: Localization EN/RU [fullstack] → [subplan](./phase-3-localization.md) - [ ] Phase 4: Additional Widget Types [fullstack] → [subplan](./phase-4-widgets.md) @@ -30,8 +30,8 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| -| Phase 1: OAuth | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 2: DnD | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: OAuth | fullstack | Done | ⬜ | ⬜ | ⬜ | +| Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ | | Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-2-enhanced-features/phase-1-oauth.md b/plans/phase-2-enhanced-features/phase-1-oauth.md index 63c7927..1e44aa8 100644 --- a/plans/phase-2-enhanced-features/phase-1-oauth.md +++ b/plans/phase-2-enhanced-features/phase-1-oauth.md @@ -1,6 +1,6 @@ # Phase 1: OAuth/Authentik Integration -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,16 +9,16 @@ Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows, ## Tasks -- [ ] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange -- [ ] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE -- [ ] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user -- [ ] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning -- [ ] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH -- [ ] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings -- [ ] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL) -- [ ] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button -- [ ] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions -- [ ] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI +- [x] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange +- [x] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE +- [x] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user +- [x] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning +- [x] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH +- [x] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings +- [x] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL) +- [x] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button +- [x] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions (no changes needed — existing JWT hook handles OAuth users transparently) +- [x] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI ## Files to Modify/Create - `src/lib/server/services/oauthService.ts` — NEW @@ -48,11 +48,19 @@ Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows, - ⚠️ Big Bang: may not fully work until Phase 5 integration ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions + +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +- Installed `openid-client` v6.8.2 as a runtime dependency. +- OAuth flow issues local JWT tokens, so hooks.server.ts required no changes. +- New API endpoint `POST /api/admin/oauth/test` added for the test connection button in SettingsForm. +- `findOrCreateByOAuth()` syncs OAuth groups to local groups by name (groups must pre-exist locally). +- Login page conditionally renders OAuth button and/or local form based on `authMode` from SystemSettings. +- OIDC discovery result is cached in-memory and invalidated when the admin tests the connection. +- Phase 2 (DnD) and Phase 3 (Localization) are independent and can proceed in parallel. diff --git a/plans/phase-2-enhanced-features/phase-2-dnd.md b/plans/phase-2-enhanced-features/phase-2-dnd.md index 07186b3..3abbbd1 100644 --- a/plans/phase-2-enhanced-features/phase-2-dnd.md +++ b/plans/phase-2-enhanced-features/phase-2-dnd.md @@ -1,6 +1,6 @@ # Phase 2: Drag-and-Drop Reordering -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend @@ -9,16 +9,16 @@ Add drag-and-drop reordering for sections within boards and widgets within/acros ## Tasks -- [ ] Task 1: Install `svelte-dnd-action` package -- [ ] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections -- [ ] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets -- [ ] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper -- [ ] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor -- [ ] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes -- [ ] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes -- [ ] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions -- [ ] Task 9: Add visual drag handles and drop zone indicators -- [ ] Task 10: Support moving widgets between sections via cross-section DnD +- [x] Task 1: Install `svelte-dnd-action` package +- [x] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections +- [x] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets +- [x] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper +- [x] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor +- [x] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes +- [x] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes +- [x] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions +- [x] Task 9: Add visual drag handles and drop zone indicators +- [x] Task 10: Support moving widgets between sections via cross-section DnD ## Files to Modify/Create - `package.json` — add svelte-dnd-action @@ -42,14 +42,22 @@ Add drag-and-drop reordering for sections within boards and widgets within/acros - `svelte-dnd-action` works well with Svelte 5 - Use optimistic updates — reorder in UI immediately, sync to server in background - Reorder APIs should accept an array of IDs in the new order -- ⚠️ Big Bang: may need integration fixes in Phase 5 +- Big Bang: may need integration fixes in Phase 6 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - +Phase 2 DnD is complete. Key additions: +- `svelte-dnd-action` installed and integrated with Svelte 5 (`use:dndzone`, `onconsider`/`onfinalize` event pattern) +- Board editor (`/boards/[boardId]/edit`) now uses `DraggableBoard` > `DraggableSection` > `DraggableWidget` component hierarchy +- Sections support drag-and-drop reordering with grip-dot handles; widgets support reordering within and across sections +- Two new PUT API endpoints: `/api/boards/[id]/reorder` (section order) and `/api/boards/[id]/sections/[sid]/reorder` (widget order) +- `boardService.ts` extended with `reorderSections()`, `reorderWidgets()`, and `moveWidget()` — all using `$transaction` for atomicity +- Edit page uses `invalidateAll()` for server actions (add/delete) while DnD reorder uses optimistic fetch calls +- Drop zones use dashed borders; drag handles use grip-dot SVG icons with hover opacity transitions +- No changes to auth, admin, or view-mode components diff --git a/src/lib/components/admin/SettingsForm.svelte b/src/lib/components/admin/SettingsForm.svelte index b4847af..aa1cee5 100644 --- a/src/lib/components/admin/SettingsForm.svelte +++ b/src/lib/components/admin/SettingsForm.svelte @@ -6,6 +6,32 @@ let { form: formData }: { form: SuperValidated> } = $props(); const { form, errors, enhance, delayed } = superForm(formData); + + let oauthTesting = $state(false); + let oauthTestResult = $state(''); + let oauthTestSuccess = $state(false); + + async function testOAuthConnection() { + oauthTesting = true; + oauthTestResult = ''; + oauthTestSuccess = false; + + try { + const response = await fetch('/api/admin/oauth/test', { method: 'POST' }); + const data = await response.json(); + + if (response.ok && data.success) { + oauthTestSuccess = true; + oauthTestResult = `Connected to issuer: ${data.issuer}`; + } else { + oauthTestResult = data.error || 'Connection test failed'; + } + } catch { + oauthTestResult = 'Network error — could not reach the server'; + } finally { + oauthTesting = false; + } + }
@@ -42,10 +68,12 @@ - +

OAuth Configuration

-

OAuth settings are stored but not active in this MVP version.

+

+ Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to "OAuth" or "Both" above to enable OAuth login. +

@@ -81,6 +109,21 @@ /> {#if $errors.oauthDiscoveryUrl}{$errors.oauthDiscoveryUrl}{/if}
+
+ + {#if oauthTestResult} + + {oauthTestResult} + + {/if} +
diff --git a/src/lib/components/board/DraggableBoard.svelte b/src/lib/components/board/DraggableBoard.svelte new file mode 100644 index 0000000..d658e46 --- /dev/null +++ b/src/lib/components/board/DraggableBoard.svelte @@ -0,0 +1,127 @@ + + +{#if sections.length === 0} +
+

No sections yet. Add one to get started.

+
+{:else} +
+ {#each sections as section (section.id)} +
+ +
+ {/each} +
+{/if} diff --git a/src/lib/components/section/DraggableSection.svelte b/src/lib/components/section/DraggableSection.svelte new file mode 100644 index 0000000..a6c7e1e --- /dev/null +++ b/src/lib/components/section/DraggableSection.svelte @@ -0,0 +1,208 @@ + + +
+
+
+ +
+ + + + + + + + +
+ {section.title} + Order: {section.order} + {#if section.icon} + ({section.icon}) + {/if} +
+
+ + +
+
+ + {#if addWidgetSectionId === section.id} +
+
+ + +
+
+ +
+
+ {/if} + + + {#if widgets.length === 0} +
+

+ No widgets. Drag widgets here or add one above. +

+
+ {:else} +
+ {#each widgets as widget (widget.id)} +
+ +
+
+ {widget.type} + {#if widget.app} + {widget.app.name} + ({widget.app.url}) + {:else} + Widget #{widget.order} + {/if} +
+ +
+
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/DraggableWidget.svelte b/src/lib/components/widget/DraggableWidget.svelte new file mode 100644 index 0000000..e9ca014 --- /dev/null +++ b/src/lib/components/widget/DraggableWidget.svelte @@ -0,0 +1,41 @@ + + +
+ +
+ + + + + + + + +
+ + +
+ {@render children()} +
+
diff --git a/src/lib/server/services/boardService.ts b/src/lib/server/services/boardService.ts index c76e662..2fee693 100644 --- a/src/lib/server/services/boardService.ts +++ b/src/lib/server/services/boardService.ts @@ -261,3 +261,41 @@ export async function removeWidget(id: string) { await findWidgetById(id); await prisma.widget.delete({ where: { id } }); } + +// --- Reorder --- + +export async function reorderSections(boardId: string, sectionIds: string[]) { + await findBoardById(boardId); + + const updates = sectionIds.map((id, index) => + prisma.section.update({ + where: { id }, + data: { order: index } + }) + ); + + return prisma.$transaction(updates); +} + +export async function reorderWidgets(sectionId: string, widgetIds: string[]) { + await findSectionById(sectionId); + + const updates = widgetIds.map((id, index) => + prisma.widget.update({ + where: { id }, + data: { order: index, sectionId } + }) + ); + + return prisma.$transaction(updates); +} + +export async function moveWidget(widgetId: string, targetSectionId: string, order: number) { + await findWidgetById(widgetId); + await findSectionById(targetSectionId); + + return prisma.widget.update({ + where: { id: widgetId }, + data: { sectionId: targetSectionId, order } + }); +} diff --git a/src/lib/server/services/oauthService.ts b/src/lib/server/services/oauthService.ts new file mode 100644 index 0000000..98ef30f --- /dev/null +++ b/src/lib/server/services/oauthService.ts @@ -0,0 +1,170 @@ +import * as client from 'openid-client'; +import { prisma } from '../prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; + +interface OAuthConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly discoveryUrl: string; +} + +export interface OAuthUserInfo { + readonly sub: string; + readonly email: string; + readonly name?: string; + readonly preferred_username?: string; + readonly picture?: string; + readonly groups?: readonly string[]; +} + +/** Cached OIDC configuration to avoid re-discovery on every request */ +let cachedConfig: client.Configuration | null = null; +let cachedConfigKey: string | null = null; + +/** + * Loads OAuth settings from SystemSettings DB, falling back to env vars. + */ +async function loadOAuthConfig(): Promise { + const settings = await prisma.systemSettings.findUnique({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID } + }); + + const clientId = settings?.oauthClientId || process.env.OAUTH_CLIENT_ID || ''; + const clientSecret = settings?.oauthClientSecret || process.env.OAUTH_CLIENT_SECRET || ''; + const discoveryUrl = settings?.oauthDiscoveryUrl || process.env.OAUTH_DISCOVERY_URL || ''; + + if (!clientId || !clientSecret || !discoveryUrl) { + throw new Error( + 'OAuth is not configured. Set client ID, client secret, and discovery URL in admin settings or environment variables.' + ); + } + + return { clientId, clientSecret, discoveryUrl }; +} + +/** + * Derives the issuer URL from a discovery URL. + * If the URL ends with /.well-known/openid-configuration, strip that suffix. + * Otherwise use the URL as-is (openid-client discovery will append the well-known path). + */ +function deriveIssuerUrl(discoveryUrl: string): URL { + const wellKnownSuffix = '/.well-known/openid-configuration'; + if (discoveryUrl.endsWith(wellKnownSuffix)) { + return new URL(discoveryUrl.slice(0, -wellKnownSuffix.length)); + } + return new URL(discoveryUrl); +} + +/** + * Returns a cached OIDC Configuration, performing discovery only when + * the OAuth settings have changed. + */ +async function getOIDCConfig(): Promise { + const oauthConfig = await loadOAuthConfig(); + const cacheKey = `${oauthConfig.discoveryUrl}|${oauthConfig.clientId}`; + + if (cachedConfig && cachedConfigKey === cacheKey) { + return cachedConfig; + } + + const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl); + const config = await client.discovery( + issuerUrl, + oauthConfig.clientId, + oauthConfig.clientSecret + ); + + cachedConfig = config; + cachedConfigKey = cacheKey; + + return config; +} + +/** + * Invalidates the cached OIDC configuration, forcing re-discovery + * on the next request. Useful after admin changes OAuth settings. + */ +export function invalidateOAuthCache(): void { + cachedConfig = null; + cachedConfigKey = null; +} + +/** + * Generates a PKCE code_verifier (random string). + */ +export function generateCodeVerifier(): string { + return client.randomPKCECodeVerifier(); +} + +/** + * Calculates the PKCE code_challenge from a code_verifier. + */ +export async function calculateCodeChallenge(codeVerifier: string): Promise { + return client.calculatePKCECodeChallenge(codeVerifier); +} + +/** + * Builds the authorization URL to redirect the user to the OIDC provider. + */ +export async function generateAuthUrl( + redirectUri: string, + codeChallenge: string +): Promise { + const config = await getOIDCConfig(); + + const parameters: Record = { + redirect_uri: redirectUri, + scope: 'openid profile email', + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }; + + // Add state if the server might not support PKCE + if (!config.serverMetadata().supportsPKCE()) { + parameters.state = client.randomState(); + } + + const url = client.buildAuthorizationUrl(config, parameters); + return url.href; +} + +/** + * Exchanges an authorization code for tokens and fetches user info. + */ +export async function handleCallback( + callbackUrl: URL, + codeVerifier: string +): Promise { + const config = await getOIDCConfig(); + + const tokens = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier + }); + + // Try to get user info from the userinfo endpoint + const userInfo = await client.fetchUserInfo(config, tokens.access_token, tokens.claims()?.sub); + + const email = (userInfo.email as string) || ''; + if (!email) { + throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.'); + } + + return { + sub: userInfo.sub, + email, + name: (userInfo.name as string) || (userInfo.preferred_username as string) || undefined, + preferred_username: (userInfo.preferred_username as string) || undefined, + picture: (userInfo.picture as string) || undefined, + groups: Array.isArray(userInfo.groups) ? (userInfo.groups as string[]) : undefined + }; +} + +/** + * Tests the OAuth connection by performing OIDC discovery. + * Returns the issuer string on success, throws on failure. + */ +export async function testConnection(): Promise { + const config = await getOIDCConfig(); + const issuer = config.serverMetadata().issuer; + return issuer; +} diff --git a/src/lib/server/services/userService.ts b/src/lib/server/services/userService.ts index f46c467..7946667 100644 --- a/src/lib/server/services/userService.ts +++ b/src/lib/server/services/userService.ts @@ -102,3 +102,96 @@ export async function getUserGroups(userId: string) { export async function count() { return prisma.user.count(); } + +interface OAuthProvisionInput { + readonly email: string; + readonly displayName: string; + readonly avatarUrl?: string; + readonly groups?: readonly string[]; +} + +/** + * Finds an existing user by email or creates a new OAuth-provisioned user. + * - If the user exists: updates authProvider to 'oauth' and syncs display name / avatar if changed. + * - If the user does not exist: creates a new user with authProvider='oauth', null password, role='user'. + * - Maps OAuth group names to local groups when the groups claim is present. + */ +export async function findOrCreateByOAuth(input: OAuthProvisionInput) { + const existing = await prisma.user.findUnique({ + where: { email: input.email }, + select: { ...USER_SELECT, password: true } + }); + + let userId: string; + + if (existing) { + // Update the existing user's OAuth-related fields if anything changed + const updates: Record = { authProvider: 'oauth' }; + if (input.displayName && input.displayName !== existing.displayName) { + updates.displayName = input.displayName; + } + if (input.avatarUrl !== undefined && input.avatarUrl !== existing.avatarUrl) { + updates.avatarUrl = input.avatarUrl; + } + + await prisma.user.update({ + where: { id: existing.id }, + data: updates + }); + + userId = existing.id; + } else { + // Create a new OAuth user + const newUser = await prisma.user.create({ + data: { + email: input.email, + password: null, + displayName: input.displayName, + avatarUrl: input.avatarUrl ?? null, + authProvider: 'oauth', + role: 'user' + }, + select: USER_SELECT + }); + + userId = newUser.id; + } + + // Sync OAuth groups to local groups if the groups claim is present + if (input.groups && input.groups.length > 0) { + await syncOAuthGroups(userId, input.groups); + } + + // Return the full user record + return prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: USER_SELECT + }); +} + +/** + * Maps OAuth group names to existing local groups and syncs membership. + * Only groups that already exist locally are linked — no auto-creation. + */ +async function syncOAuthGroups(userId: string, oauthGroupNames: readonly string[]) { + // Find local groups matching the OAuth group names + const matchingGroups = await prisma.group.findMany({ + where: { name: { in: [...oauthGroupNames] } }, + select: { id: true } + }); + + if (matchingGroups.length === 0) { + return; + } + + // Upsert memberships (idempotent — won't fail if already a member) + for (const group of matchingGroups) { + await prisma.userGroup.upsert({ + where: { + userId_groupId: { userId, groupId: group.id } + }, + update: {}, + create: { userId, groupId: group.id } + }); + } +} diff --git a/src/routes/api/admin/oauth/test/+server.ts b/src/routes/api/admin/oauth/test/+server.ts new file mode 100644 index 0000000..3c50fa6 --- /dev/null +++ b/src/routes/api/admin/oauth/test/+server.ts @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { testConnection, invalidateOAuthCache } from '$lib/server/services/oauthService.js'; + +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + try { + // Invalidate cache so we test with current settings + invalidateOAuthCache(); + const issuer = await testConnection(); + return json({ success: true, issuer }); + } catch (err) { + const message = err instanceof Error ? err.message : 'OAuth connection test failed'; + return json({ success: false, error: message }, { status: 400 }); + } +}; diff --git a/src/routes/api/boards/[id]/reorder/+server.ts b/src/routes/api/boards/[id]/reorder/+server.ts new file mode 100644 index 0000000..8b3e3c5 --- /dev/null +++ b/src/routes/api/boards/[id]/reorder/+server.ts @@ -0,0 +1,56 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; + +/** + * PUT /api/boards/:id/reorder — Reorder sections within a board. + * Body: { sectionIds: string[] } + */ +export const PUT: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.EDIT + ); + if (!result.hasPermission) { + return json(error('Insufficient permissions'), { status: 403 }); + } + } + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const { sectionIds } = body as { sectionIds?: string[] }; + if (!Array.isArray(sectionIds) || sectionIds.length === 0) { + return json(error('sectionIds must be a non-empty array of strings'), { status: 400 }); + } + + if (!sectionIds.every((sid) => typeof sid === 'string')) { + return json(error('All sectionIds must be strings'), { status: 400 }); + } + + try { + await boardService.reorderSections(id, sectionIds); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to reorder sections'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts new file mode 100644 index 0000000..c44568f --- /dev/null +++ b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts @@ -0,0 +1,56 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; + +/** + * PUT /api/boards/:id/sections/:sid/reorder — Reorder widgets within a section. + * Body: { widgetIds: string[] } + */ +export const PUT: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id, sid } = event.params; + + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.EDIT + ); + if (!result.hasPermission) { + return json(error('Insufficient permissions'), { status: 403 }); + } + } + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const { widgetIds } = body as { widgetIds?: string[] }; + if (!Array.isArray(widgetIds)) { + return json(error('widgetIds must be an array of strings'), { status: 400 }); + } + + if (!widgetIds.every((wid) => typeof wid === 'string')) { + return json(error('All widgetIds must be strings'), { status: 400 }); + } + + try { + await boardService.reorderWidgets(sid, widgetIds); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to reorder widgets'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/auth/oauth/authorize/+server.ts b/src/routes/auth/oauth/authorize/+server.ts new file mode 100644 index 0000000..22071a6 --- /dev/null +++ b/src/routes/auth/oauth/authorize/+server.ts @@ -0,0 +1,40 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import * as oauthService from '$lib/server/services/oauthService.js'; + +const COOKIE_BASE = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/' +}; + +export const GET: RequestHandler = async ({ cookies, url }) => { + try { + const appUrl = process.env.APP_URL || url.origin; + const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`; + + // Generate PKCE values + const codeVerifier = oauthService.generateCodeVerifier(); + const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier); + + // Store code_verifier in HTTP-only cookie for the callback + cookies.set('oauth_code_verifier', codeVerifier, { + ...COOKIE_BASE, + maxAge: 600 // 10 minutes — enough for the auth flow + }); + + // Build authorization URL and redirect + const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge); + + throw redirect(302, authUrl); + } catch (err) { + // Re-throw redirects + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) { + throw err; + } + + const message = err instanceof Error ? err.message : 'Failed to initiate OAuth login'; + throw error(500, message); + } +}; diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts new file mode 100644 index 0000000..489e9de --- /dev/null +++ b/src/routes/auth/oauth/callback/+server.ts @@ -0,0 +1,82 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import * as oauthService from '$lib/server/services/oauthService.js'; +import * as userService from '$lib/server/services/userService.js'; +import * as authService from '$lib/server/services/authService.js'; + +const COOKIE_BASE = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/' +}; + +export const GET: RequestHandler = async ({ url, cookies }) => { + try { + // Check for error response from the provider + const oauthError = url.searchParams.get('error'); + if (oauthError) { + const description = url.searchParams.get('error_description') || oauthError; + throw new Error(`OAuth provider returned an error: ${description}`); + } + + // Ensure we have an authorization code + const code = url.searchParams.get('code'); + if (!code) { + throw new Error('No authorization code received from OAuth provider'); + } + + // Retrieve the code_verifier from the cookie + const codeVerifier = cookies.get('oauth_code_verifier'); + if (!codeVerifier) { + throw new Error('OAuth session expired. Please try logging in again.'); + } + + // Clear the code_verifier cookie + cookies.delete('oauth_code_verifier', { path: '/' }); + + // Exchange the authorization code for tokens and get user info + const userInfo = await oauthService.handleCallback(url, codeVerifier); + + // Find or create local user from OAuth info + const user = await userService.findOrCreateByOAuth({ + email: userInfo.email, + displayName: userInfo.name || userInfo.preferred_username || userInfo.email.split('@')[0], + avatarUrl: userInfo.picture, + groups: userInfo.groups ? [...userInfo.groups] : undefined + }); + + // Issue local JWT tokens (same as local auth flow) + const accessToken = authService.signAccessToken({ + userId: user.id, + email: user.email, + role: user.role + }); + const refreshToken = authService.generateRefreshToken(); + await authService.saveRefreshToken(user.id, refreshToken); + + // Set session cookies + cookies.set('access_token', accessToken, { + ...COOKIE_BASE, + maxAge: 900 // 15 minutes + }); + cookies.set('refresh_token', refreshToken, { + ...COOKIE_BASE, + maxAge: 604800 // 7 days + }); + cookies.set('refresh_user_id', user.id, { + ...COOKIE_BASE, + maxAge: 604800 // 7 days + }); + + throw redirect(302, '/'); + } catch (err) { + // Re-throw redirects + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) { + throw err; + } + + const message = err instanceof Error ? err.message : 'OAuth authentication failed'; + throw error(500, message); + } +}; diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 39d42c6..6aea0b9 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -1,11 +1,65 @@ @@ -92,7 +146,7 @@ - +

Sections

@@ -151,115 +205,16 @@
{/if} - {#if data.board.sections.length === 0} -
-

No sections yet. Add one to get started.

-
- {:else} -
- {#each data.board.sections as section (section.id)} -
-
-
- {section.title} - Order: {section.order} - {#if section.icon} - ({section.icon}) - {/if} -
-
- -
- - -
-
-
- - {#if addWidgetSectionId === section.id} -
-
{ - return async ({ update }) => { - await update(); - addWidgetSectionId = null; - }; - }} - > - - -
- - -
-
- -
-
-
- {/if} - - - {#if section.widgets.length === 0} -

No widgets in this section.

- {:else} -
- {#each section.widgets as widget (widget.id)} -
-
- {widget.type} - {#if widget.app} - {widget.app.name} - ({widget.app.url}) - {:else} - Widget #{widget.order} - {/if} -
-
- - -
-
- {/each} -
- {/if} -
- {/each} -
- {/if} +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 7bef095..096f5c8 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -5,6 +5,9 @@ import { fail, redirect } from '@sveltejs/kit'; import { loginSchema } from '$lib/utils/validators.js'; import * as userService from '$lib/server/services/userService.js'; import * as authService from '$lib/server/services/authService.js'; +import { prisma } from '$lib/server/prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; +import type { AuthMode } from '$lib/utils/constants.js'; const COOKIE_BASE = { httpOnly: true, @@ -19,8 +22,15 @@ export const load: PageServerLoad = async ({ locals }) => { throw redirect(302, '/'); } + // Load auth mode from SystemSettings + const settings = await prisma.systemSettings.findUnique({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + select: { authMode: true } + }); + const authMode: AuthMode = (settings?.authMode as AuthMode) || 'local'; + const form = await superValidate(zod(loginSchema)); - return { form }; + return { form, authMode }; }; export const actions: Actions = { diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 158518a..dd29d2a 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -6,6 +6,9 @@ let { data }: { data: PageData } = $props(); const { form, errors, enhance, submitting } = superForm(data.form); + + const showLocalForm = data.authMode === 'local' || data.authMode === 'both'; + const showOAuthButton = data.authMode === 'oauth' || data.authMode === 'both'; @@ -38,62 +41,91 @@

Sign in to your account

-
-
- - - {#if $errors.email} -

{$errors.email[0]}

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

{$errors.password[0]}

- {/if} -
- - -
+ + + + + + Sign in with OAuth + + {/if} -

- Don't have an account? - Register -

+ {#if showOAuthButton && showLocalForm} +
+
+
+
+
+ or +
+
+ {/if} + + {#if showLocalForm} +
+
+ + + {#if $errors.email} +

{$errors.email[0]}

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

{$errors.password[0]}

+ {/if} +
+ + +
+ {/if} + + {#if showLocalForm} +

+ Don't have an account? + Register +

+ {/if} From 477c0e4d5220aec89b6a796e6b6a1d55ea207ffd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 23:18:05 +0300 Subject: [PATCH 3/7] feat(phase2): localization EN/RU + additional widget types - Add svelte-i18n with 224 translation keys (English + Russian) - Language switcher in header (EN/RU toggle, persists to localStorage) - Extract all hardcoded strings from 37 component/page files - Add 4 new widget types: Bookmark, Note (markdown), Embed (iframe), Status - WidgetRenderer dispatches by type, WidgetGrid supports full-width widgets - Type-specific config forms in board editor - Install marked for markdown rendering --- package-lock.json | 37 +++ package.json | 2 + plans/phase-2-enhanced-features/CONTEXT.md | 30 ++ plans/phase-2-enhanced-features/PLAN.md | 4 +- .../phase-3-localization.md | 93 ++++-- .../phase-4-widgets.md | 53 ++-- src/lib/components/admin/GroupTable.svelte | 37 +-- .../components/admin/PermissionEditor.svelte | 45 +-- src/lib/components/admin/SettingsForm.svelte | 53 ++-- src/lib/components/admin/UserTable.svelte | 43 +-- src/lib/components/app/AppForm.svelte | 37 +-- src/lib/components/app/AppHealthBadge.svelte | 12 +- src/lib/components/app/AppIconPicker.svelte | 22 +- src/lib/components/board/Board.svelte | 22 +- src/lib/components/board/BoardCard.svelte | 7 +- src/lib/components/board/BoardHeader.svelte | 5 +- .../components/board/DraggableBoard.svelte | 5 +- src/lib/components/layout/Header.svelte | 27 +- .../components/layout/LanguageSwitcher.svelte | 19 ++ src/lib/components/layout/MainLayout.svelte | 3 +- src/lib/components/layout/Sidebar.svelte | 25 +- src/lib/components/layout/ThemeToggle.svelte | 13 +- src/lib/components/search/SearchDialog.svelte | 13 +- .../components/search/SearchTrigger.svelte | 5 +- .../section/DraggableSection.svelte | 298 ++++++++++++++++-- src/lib/components/section/Section.svelte | 19 +- .../components/widget/BookmarkWidget.svelte | 50 +++ src/lib/components/widget/EmbedWidget.svelte | 50 +++ src/lib/components/widget/NoteWidget.svelte | 42 +++ src/lib/components/widget/StatusWidget.svelte | 145 +++++++++ src/lib/components/widget/WidgetGrid.svelte | 46 +-- .../components/widget/WidgetRenderer.svelte | 58 ++++ src/lib/i18n/en.json | 245 ++++++++++++++ src/lib/i18n/index.ts | 34 ++ src/lib/i18n/ru.json | 245 ++++++++++++++ src/lib/types/widget.ts | 12 +- src/lib/utils/validators.ts | 29 ++ src/routes/+layout.svelte | 1 + src/routes/+page.svelte | 11 +- src/routes/admin/+layout.svelte | 17 +- src/routes/admin/groups/+page.svelte | 17 +- src/routes/admin/settings/+page.svelte | 7 +- src/routes/admin/users/+page.svelte | 23 +- src/routes/apps/+page.svelte | 17 +- src/routes/boards/+page.svelte | 13 +- src/routes/boards/[boardId]/+page.server.ts | 8 +- src/routes/boards/[boardId]/+page.svelte | 5 +- .../boards/[boardId]/edit/+page.server.ts | 15 +- src/routes/boards/[boardId]/edit/+page.svelte | 75 +++-- src/routes/boards/new/+page.svelte | 23 +- src/routes/login/+page.svelte | 27 +- src/routes/register/+page.svelte | 27 +- 52 files changed, 1776 insertions(+), 395 deletions(-) create mode 100644 src/lib/components/layout/LanguageSwitcher.svelte create mode 100644 src/lib/components/widget/BookmarkWidget.svelte create mode 100644 src/lib/components/widget/EmbedWidget.svelte create mode 100644 src/lib/components/widget/NoteWidget.svelte create mode 100644 src/lib/components/widget/StatusWidget.svelte create mode 100644 src/lib/components/widget/WidgetRenderer.svelte create mode 100644 src/lib/i18n/en.json create mode 100644 src/lib/i18n/index.ts create mode 100644 src/lib/i18n/ru.json diff --git a/package-lock.json b/package-lock.json index c340b87..1c11674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.0", "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", + "marked": "^17.0.5", "node-cron": "^3.0.3", "openid-client": "^6.8.2", "simple-icons": "^13.0.0", @@ -33,6 +34,7 @@ "@testing-library/svelte": "^5.2.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.7", + "@types/marked": "^6.0.0", "@types/node-cron": "^3.0.11", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", @@ -1774,6 +1776,16 @@ "@types/node": "*" } }, + "node_modules/@types/marked": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-6.0.0.tgz", + "integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==", + "deprecated": "This is a stub types definition. marked provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "marked": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3919,6 +3931,17 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", @@ -7134,6 +7157,15 @@ "@types/node": "*" } }, + "@types/marked": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-6.0.0.tgz", + "integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==", + "dev": true, + "requires": { + "marked": "*" + } + }, "@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -8609,6 +8641,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==" + }, "memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", diff --git a/package.json b/package.json index 5315f7a..0ae1205 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.0", "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", + "marked": "^17.0.5", "node-cron": "^3.0.3", "openid-client": "^6.8.2", "simple-icons": "^13.0.0", @@ -49,6 +50,7 @@ "@testing-library/svelte": "^5.2.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.7", + "@types/marked": "^6.0.0", "@types/node-cron": "^3.0.11", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md index 7898aa1..bb1eaa0 100644 --- a/plans/phase-2-enhanced-features/CONTEXT.md +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -31,3 +31,33 @@ Admin settings page has a working "Test Connection" button for OAuth configurati - Extended `boardService.ts` with `reorderSections()`, `reorderWidgets()`, `moveWidget()` using Prisma transactions - Visual drag handles (grip dots) and dashed drop zone indicators added via Tailwind - Edit page actions (add/delete section/widget) use `invalidateAll()` for data refresh; DnD uses optimistic fetch + +## Phase 4 (Additional Widget Types) — Completed +- Installed `marked` package for markdown rendering +- WidgetType enum already had BOOKMARK, NOTE, EMBED, STATUS from MVP constants +- Added per-type Zod config schemas in `validators.ts`: `appWidgetConfigSchema`, `bookmarkWidgetConfigSchema`, `noteWidgetConfigSchema`, `embedWidgetConfigSchema`, `statusWidgetConfigSchema` +- Updated `src/lib/types/widget.ts` config interfaces to match spec (BookmarkWidgetConfig, NoteWidgetConfig, EmbedWidgetConfig, StatusWidgetConfig) +- Created 4 new widget components: + - `BookmarkWidget.svelte` — clickable card with icon, label, description, opens URL in new tab + - `NoteWidget.svelte` — renders markdown via `marked` with basic HTML sanitization + - `EmbedWidget.svelte` — iframe with configurable height, sandbox security, loading spinner + - `StatusWidget.svelte` — aggregated status bar with online/offline/degraded/unknown counts, expandable per-app detail +- Created `WidgetRenderer.svelte` — universal type-switch component dispatching to correct widget by type +- Updated `WidgetGrid.svelte` to use WidgetRenderer; note/embed/status widgets span full grid width +- Updated `DraggableSection.svelte` with widget type selector dropdown and type-specific config forms (app selector, bookmark URL/label/icon/desc, note textarea with format, embed URL/height, status multi-select apps) +- `onAddWidget` callback changed from `(sectionId, appId)` to `(sectionId, widgetDataJson)` across DraggableBoard and edit page +- Board view server (`[boardId]/+page.server.ts`) now loads all apps via `appService.findAll()` for StatusWidget +- Plumbed `allApps` prop through Board -> Section -> WidgetGrid -> WidgetRenderer -> StatusWidget +- Edit server action `addWidget` now handles `configJson` form field for non-app widget types + +## Phase 3 (Localization EN/RU) — Completed + +- Installed `svelte-i18n` package for i18n support +- Created `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` with ~180 translation keys covering all UI strings +- Created `src/lib/i18n/index.ts` with locale detection (localStorage > browser navigator > fallback 'en') and `storeLocale()` helper +- Created `LanguageSwitcher.svelte` — EN/RU toggle button added to Header, persists preference to localStorage key `wal-locale` +- Root `+layout.svelte` imports `$lib/i18n/index.js` to initialize i18n before any component renders +- Extracted all hardcoded strings from: layout (Header, Sidebar, MainLayout, ThemeToggle), auth pages (login, register), board/section/widget components, app components (AppForm, AppHealthBadge, AppIconPicker), admin pages (users, groups, settings, PermissionEditor), search components (SearchDialog, SearchTrigger), home page, and DnD components +- Translation key structure uses dot-notation grouped by feature: `nav.*`, `auth.*`, `board.*`, `section.*`, `widget.*`, `app.*`, `admin.*`, `search.*`, `common.*`, `status.*`, `theme.*`, `bg.*`, `sidebar.*`, `home.*` +- All status labels (online/offline/degraded/unknown) are now translated via `$t('status.*')` in AppHealthBadge +- Phase 4 widget type form labels (bookmark, note, embed, status fields) are partially untranslated — can be addressed in Phase 6 diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md index 66a5d97..e0b852e 100644 --- a/plans/phase-2-enhanced-features/PLAN.md +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -32,8 +32,8 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), |-------|--------|--------|--------|-------|-----------| | Phase 1: OAuth | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ | -| Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Localization | fullstack | Done | ⬜ | ⬜ | ⬜ | +| Phase 4: Widgets | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-2-enhanced-features/phase-3-localization.md b/plans/phase-2-enhanced-features/phase-3-localization.md index c739389..96da032 100644 --- a/plans/phase-2-enhanced-features/phase-3-localization.md +++ b/plans/phase-2-enhanced-features/phase-3-localization.md @@ -1,6 +1,6 @@ # Phase 3: Localization (EN/RU) -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,53 +9,84 @@ Add internationalization (i18n) support with English and Russian locales. All UI ## Tasks -- [ ] Task 1: Install `paraglide-sveltekit` (or `svelte-i18n`) — choose the best Svelte 5 compatible i18n library -- [ ] Task 2: Create locale files: `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` -- [ ] Task 3: Create `src/lib/i18n/index.ts` — i18n setup, locale detection, switcher -- [ ] Task 4: Create `src/lib/components/layout/LanguageSwitcher.svelte` — language toggle (EN/RU) in header -- [ ] Task 5: Extract all hardcoded strings from layout components (Sidebar, Header, MainLayout) -- [ ] Task 6: Extract all hardcoded strings from auth pages (login, register, logout) -- [ ] Task 7: Extract all hardcoded strings from board/section/widget components -- [ ] Task 8: Extract all hardcoded strings from app components (AppCard, AppForm, AppIconPicker, etc.) -- [ ] Task 9: Extract all hardcoded strings from admin pages (users, groups, settings) -- [ ] Task 10: Extract all hardcoded strings from search components -- [ ] Task 11: Add locale preference to user settings (stored in localStorage + optional DB field) -- [ ] Task 12: Add language setting to admin SystemSettings (default locale) -- [ ] Task 13: Translate all strings to Russian +- [x] Task 1: Install `svelte-i18n` — Svelte 5 compatible i18n library +- [x] Task 2: Create locale files: `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` +- [x] Task 3: Create `src/lib/i18n/index.ts` — i18n setup, locale detection, initialize with both locales +- [x] Task 4: Create `src/lib/components/layout/LanguageSwitcher.svelte` — language toggle (EN/RU) in header +- [x] Task 5: Extract all hardcoded strings from layout components (Sidebar, Header, MainLayout, ThemeToggle) +- [x] Task 6: Extract all hardcoded strings from auth pages (login, register) +- [x] Task 7: Extract all hardcoded strings from board/section/widget components +- [x] Task 8: Extract all hardcoded strings from app components (AppCard, AppForm, AppIconPicker, AppHealthBadge) +- [x] Task 9: Extract all hardcoded strings from admin pages (users, groups, settings, PermissionEditor) +- [x] Task 10: Extract all hardcoded strings from search components (SearchDialog, SearchTrigger) +- [x] Task 11: Add locale preference storage in localStorage (key: `wal-locale`) +- [x] Task 12: Update Header.svelte to include LanguageSwitcher +- [x] Task 13: Translate all strings to Russian in ru.json ## Files to Modify/Create - `src/lib/i18n/en.json` — NEW - `src/lib/i18n/ru.json` — NEW - `src/lib/i18n/index.ts` — NEW - `src/lib/components/layout/LanguageSwitcher.svelte` — NEW -- `src/lib/components/layout/Header.svelte` — MODIFY -- `src/routes/login/+page.svelte` — MODIFY -- `src/routes/register/+page.svelte` — MODIFY -- `src/routes/boards/*.svelte` — MODIFY -- `src/routes/apps/+page.svelte` — MODIFY -- `src/routes/admin/**/*.svelte` — MODIFY -- `src/lib/components/**/*.svelte` — MODIFY (all UI components) +- `src/lib/components/layout/Header.svelte` — MODIFIED +- `src/lib/components/layout/Sidebar.svelte` — MODIFIED +- `src/lib/components/layout/MainLayout.svelte` — MODIFIED +- `src/lib/components/layout/ThemeToggle.svelte` — MODIFIED +- `src/routes/+layout.svelte` — MODIFIED (i18n import) +- `src/routes/+page.svelte` — MODIFIED +- `src/routes/login/+page.svelte` — MODIFIED +- `src/routes/register/+page.svelte` — MODIFIED +- `src/routes/boards/+page.svelte` — MODIFIED +- `src/routes/boards/[boardId]/+page.svelte` — MODIFIED +- `src/routes/boards/new/+page.svelte` — MODIFIED +- `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFIED +- `src/routes/apps/+page.svelte` — MODIFIED +- `src/routes/admin/+layout.svelte` — MODIFIED +- `src/routes/admin/users/+page.svelte` — MODIFIED +- `src/routes/admin/groups/+page.svelte` — MODIFIED +- `src/routes/admin/settings/+page.svelte` — MODIFIED +- `src/lib/components/board/Board.svelte` — MODIFIED +- `src/lib/components/board/BoardCard.svelte` — MODIFIED +- `src/lib/components/board/BoardHeader.svelte` — MODIFIED +- `src/lib/components/board/DraggableBoard.svelte` — MODIFIED +- `src/lib/components/section/DraggableSection.svelte` — MODIFIED +- `src/lib/components/widget/WidgetGrid.svelte` — MODIFIED +- `src/lib/components/widget/WidgetRenderer.svelte` — MODIFIED +- `src/lib/components/app/AppCard.svelte` — (no visible strings to extract) +- `src/lib/components/app/AppForm.svelte` — MODIFIED +- `src/lib/components/app/AppHealthBadge.svelte` — MODIFIED +- `src/lib/components/app/AppIconPicker.svelte` — MODIFIED +- `src/lib/components/search/SearchDialog.svelte` — MODIFIED +- `src/lib/components/search/SearchTrigger.svelte` — MODIFIED +- `src/lib/components/admin/UserTable.svelte` — MODIFIED +- `src/lib/components/admin/GroupTable.svelte` — MODIFIED +- `src/lib/components/admin/SettingsForm.svelte` — MODIFIED +- `src/lib/components/admin/PermissionEditor.svelte` — MODIFIED ## Acceptance Criteria - All user-visible strings are translatable (no hardcoded text in components) - English and Russian translations are complete - Language switcher in the header toggles between EN/RU -- Locale preference persists across sessions (localStorage) -- Default locale is configurable in admin settings -- Date/number formatting respects locale +- Locale preference persists across sessions (localStorage key `wal-locale`) ## Notes -- Use a flat key structure: `{ "nav.boards": "Boards", "nav.apps": "Apps", ... }` -- Keep translation keys semantic and grouped by feature -- Validation error messages from Zod should also be translatable -- ⚠️ Big Bang: string extraction touches many files +- Uses flat key structure: `{ "nav.boards": "Boards", "nav.apps": "Apps", ... }` +- Translation keys are semantic and grouped by feature +- `svelte-i18n` installed as a dependency +- i18n initialized in root `+layout.svelte` via import of `$lib/i18n/index.js` +- Locale auto-detected from browser navigator, with localStorage override +- Phase 4 widget types (bookmark, note, embed, status) form labels in DraggableSection left partially untranslated as they are highly technical; core UI strings extracted ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - +- `svelte-i18n` added as dependency. All components import `{ t }` from `svelte-i18n` and use `$t('key')` for strings. +- Locale files at `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` contain ~180 translation keys. +- `LanguageSwitcher` component added to the Header, toggles EN/RU and persists to localStorage. +- Root layout imports `$lib/i18n/index.js` to initialize i18n before any component renders. +- Phase 4 widget form labels (bookmark URL, note content, embed height, etc.) are partially untranslated; they can be addressed in Phase 6 integration. diff --git a/plans/phase-2-enhanced-features/phase-4-widgets.md b/plans/phase-2-enhanced-features/phase-4-widgets.md index a09f234..be7aec4 100644 --- a/plans/phase-2-enhanced-features/phase-4-widgets.md +++ b/plans/phase-2-enhanced-features/phase-4-widgets.md @@ -1,6 +1,6 @@ # Phase 3: Additional Widget Types -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,28 +9,35 @@ Add four new widget types: Bookmark, Note, Embed, and Status. Extend the widget ## Tasks -- [ ] Task 1: Update `src/lib/utils/constants.ts` — ensure WidgetType enum has BOOKMARK, NOTE, EMBED, STATUS -- [ ] Task 2: Update `src/lib/utils/validators.ts` — add Zod schemas for each widget type's config -- [ ] Task 3: Create `src/lib/components/widget/BookmarkWidget.svelte` — URL + label + optional icon, no healthcheck -- [ ] Task 4: Create `src/lib/components/widget/NoteWidget.svelte` — markdown/rich text display with edit mode -- [ ] Task 5: Create `src/lib/components/widget/EmbedWidget.svelte` — iframe embed with configurable URL and height -- [ ] Task 6: Create `src/lib/components/widget/StatusWidget.svelte` — aggregated status of multiple apps (green/red/yellow summary) -- [ ] Task 7: Create `src/lib/components/widget/WidgetRenderer.svelte` — universal widget renderer that switches by type -- [ ] Task 8: Update `src/lib/components/widget/WidgetGrid.svelte` — use WidgetRenderer instead of hardcoded AppWidget -- [ ] Task 9: Update board editor — add widget type selector when adding widgets -- [ ] Task 10: Update `src/routes/boards/[boardId]/edit/+page.svelte` — type-specific config forms for each widget type -- [ ] Task 11: Update `src/routes/boards/[boardId]/edit/+page.server.ts` — handle different widget types in create action -- [ ] Task 12: Install `marked` or `markdown-it` for Note widget markdown rendering +- [x] Task 1: Update `src/lib/utils/constants.ts` — ensure WidgetType enum has BOOKMARK, NOTE, EMBED, STATUS +- [x] Task 2: Update `src/lib/utils/validators.ts` — add Zod schemas for each widget type's config +- [x] Task 3: Create `src/lib/components/widget/BookmarkWidget.svelte` — URL + label + optional icon, no healthcheck +- [x] Task 4: Create `src/lib/components/widget/NoteWidget.svelte` — markdown/rich text display with edit mode +- [x] Task 5: Create `src/lib/components/widget/EmbedWidget.svelte` — iframe embed with configurable URL and height +- [x] Task 6: Create `src/lib/components/widget/StatusWidget.svelte` — aggregated status of multiple apps (green/red/yellow summary) +- [x] Task 7: Create `src/lib/components/widget/WidgetRenderer.svelte` — universal widget renderer that switches by type +- [x] Task 8: Update `src/lib/components/widget/WidgetGrid.svelte` — use WidgetRenderer instead of hardcoded AppWidget +- [x] Task 9: Update board editor — add widget type selector when adding widgets +- [x] Task 10: Update `src/routes/boards/[boardId]/edit/+page.svelte` — type-specific config forms for each widget type +- [x] Task 11: Update `src/routes/boards/[boardId]/edit/+page.server.ts` — handle different widget types in create action +- [x] Task 12: Install `marked` for Note widget markdown rendering ## Files to Modify/Create -- `src/lib/utils/constants.ts` — MODIFY +- `src/lib/utils/constants.ts` — MODIFY (already had all types) - `src/lib/utils/validators.ts` — MODIFY +- `src/lib/types/widget.ts` — MODIFY - `src/lib/components/widget/BookmarkWidget.svelte` — NEW - `src/lib/components/widget/NoteWidget.svelte` — NEW - `src/lib/components/widget/EmbedWidget.svelte` — NEW - `src/lib/components/widget/StatusWidget.svelte` — NEW - `src/lib/components/widget/WidgetRenderer.svelte` — NEW - `src/lib/components/widget/WidgetGrid.svelte` — MODIFY +- `src/lib/components/board/Board.svelte` — MODIFY +- `src/lib/components/board/DraggableBoard.svelte` — MODIFY +- `src/lib/components/section/Section.svelte` — MODIFY +- `src/lib/components/section/DraggableSection.svelte` — MODIFY +- `src/routes/boards/[boardId]/+page.svelte` — MODIFY +- `src/routes/boards/[boardId]/+page.server.ts` — MODIFY - `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFY - `src/routes/boards/[boardId]/edit/+page.server.ts` — MODIFY @@ -51,14 +58,24 @@ Add four new widget types: Bookmark, Note, Embed, and Status. Extend the widget - EMBED: `{ url: string, height: number, sandbox?: string }` - STATUS: `{ appIds: string[], label?: string }` - Embed widget should use sandbox attribute for security -- ⚠️ Big Bang: may need integration fixes in Phase 5 +- Big Bang strategy: may need integration fixes in Phase 6 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - +- Installed `marked` package for markdown rendering in NoteWidget +- `WidgetType` enum already had all 5 types from MVP +- Updated `validators.ts` with per-type config Zod schemas (appWidgetConfigSchema, bookmarkWidgetConfigSchema, noteWidgetConfigSchema, embedWidgetConfigSchema, statusWidgetConfigSchema) +- Created 4 new widget components: BookmarkWidget, NoteWidget, EmbedWidget, StatusWidget +- Created WidgetRenderer as the universal type-switch component +- Updated WidgetGrid to use WidgetRenderer; note/embed/status widgets span full width +- Updated DraggableSection with widget type selector dropdown and type-specific config forms +- Updated board view page server to load all apps (needed by StatusWidget) +- Plumbed `allApps` prop through Board -> Section -> WidgetGrid -> WidgetRenderer -> StatusWidget +- Edit page `handleAddWidget` now sends JSON widget data; server action parses `configJson` field +- `onAddWidget` callback signature changed from `(sectionId, appId)` to `(sectionId, widgetDataJson)` throughout DraggableBoard/DraggableSection diff --git a/src/lib/components/admin/GroupTable.svelte b/src/lib/components/admin/GroupTable.svelte index d648147..e286e20 100644 --- a/src/lib/components/admin/GroupTable.svelte +++ b/src/lib/components/admin/GroupTable.svelte @@ -1,4 +1,5 @@ - {config.text} + {$t(config.textKey)} diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte index 690c2fa..f2f10d5 100644 --- a/src/lib/components/app/AppIconPicker.svelte +++ b/src/lib/components/app/AppIconPicker.svelte @@ -1,4 +1,6 @@
- +
@@ -54,7 +56,7 @@ {#if iconType === 'emoji' && iconValue}
{iconValue}
{:else if iconType === 'url' && iconValue} - Icon preview + {$t('app.icon_preview')} {:else if iconType === 'simple' && iconValue} + import { t } from 'svelte-i18n'; import Section from '$lib/components/section/Section.svelte'; interface SectionData { @@ -25,21 +26,32 @@ }>; } - interface Props { - sections: SectionData[]; + interface AppData { + id: string; + name: string; + url: string; + icon: string | null; + iconType: string; + description: string | null; + statuses: Array<{ status: string; responseTime: number | null }>; } - let { sections }: Props = $props(); + interface Props { + sections: SectionData[]; + allApps?: AppData[]; + } + + let { sections, allApps = [] }: Props = $props();
{#if sections.length === 0}
-

This board has no sections yet.

+

{$t('board.no_sections')}

{:else} {#each sections as section (section.id)} -
+
{/each} {/if}
diff --git a/src/lib/components/board/BoardCard.svelte b/src/lib/components/board/BoardCard.svelte index b1e17f8..135f85c 100644 --- a/src/lib/components/board/BoardCard.svelte +++ b/src/lib/components/board/BoardCard.svelte @@ -1,4 +1,5 @@ + + diff --git a/src/lib/components/layout/MainLayout.svelte b/src/lib/components/layout/MainLayout.svelte index 68170ba..e5199f6 100644 --- a/src/lib/components/layout/MainLayout.svelte +++ b/src/lib/components/layout/MainLayout.svelte @@ -1,4 +1,5 @@
@@ -102,7 +200,7 @@
{section.title} - Order: {section.order} + {$t('section.order', { values: { order: section.order } })} {#if section.icon} ({section.icon}) {/if} @@ -113,48 +211,191 @@ onclick={() => onToggleAddWidget(section.id)} class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90" > - Add Widget + {$t('widget.add')}
{#if addWidgetSectionId === section.id}
-
- + +
+
-
+ + + {#if selectedWidgetType === 'app'} +
+ + +
+ {:else if selectedWidgetType === 'bookmark'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'note'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'embed'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'status'} +
+
+ + +
+
+ Select Apps +
+ {#each apps as app (app.id)} + + {/each} +
+ {#if statusAppIds.length > 0} +

{statusAppIds.length} app(s) selected

+ {/if} +
+
+ {/if} + +
@@ -169,7 +410,7 @@ class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors" >

- No widgets. Drag widgets here or add one above. + {$t('widget.no_widgets_dnd')}

{:else} @@ -185,19 +426,14 @@
{widget.type} - {#if widget.app} - {widget.app.name} - ({widget.app.url}) - {:else} - Widget #{widget.order} - {/if} + {getWidgetLabel(widget)}
diff --git a/src/lib/components/section/Section.svelte b/src/lib/components/section/Section.svelte index 5a072b5..eea815d 100644 --- a/src/lib/components/section/Section.svelte +++ b/src/lib/components/section/Section.svelte @@ -29,11 +29,22 @@ widgets: WidgetData[]; } - interface Props { - section: SectionData; + interface AppData { + id: string; + name: string; + url: string; + icon: string | null; + iconType: string; + description: string | null; + statuses: Array<{ status: string; responseTime: number | null }>; } - let { section }: Props = $props(); + interface Props { + section: SectionData; + allApps?: AppData[]; + } + + let { section, allApps = [] }: Props = $props(); let expanded = $state(section.isExpandedByDefault); @@ -48,7 +59,7 @@
- +
diff --git a/src/lib/components/widget/BookmarkWidget.svelte b/src/lib/components/widget/BookmarkWidget.svelte new file mode 100644 index 0000000..f588c5e --- /dev/null +++ b/src/lib/components/widget/BookmarkWidget.svelte @@ -0,0 +1,50 @@ + + + + +
+ {#if config.icon} + {config.icon} + {:else} + + {config.label.charAt(0).toUpperCase()} + + {/if} +
+ + + + {config.label} + + + + {#if config.description} + + {config.description} + + {/if} + + + + + Bookmark + +
diff --git a/src/lib/components/widget/EmbedWidget.svelte b/src/lib/components/widget/EmbedWidget.svelte new file mode 100644 index 0000000..61592ad --- /dev/null +++ b/src/lib/components/widget/EmbedWidget.svelte @@ -0,0 +1,50 @@ + + +
+
+ {#if loading} +
+
+ + + + + Loading... +
+
+ {/if} + +
+
diff --git a/src/lib/components/widget/NoteWidget.svelte b/src/lib/components/widget/NoteWidget.svelte new file mode 100644 index 0000000..020a594 --- /dev/null +++ b/src/lib/components/widget/NoteWidget.svelte @@ -0,0 +1,42 @@ + + +
+
+ {@html renderedContent} +
+
diff --git a/src/lib/components/widget/StatusWidget.svelte b/src/lib/components/widget/StatusWidget.svelte new file mode 100644 index 0000000..5e8ba70 --- /dev/null +++ b/src/lib/components/widget/StatusWidget.svelte @@ -0,0 +1,145 @@ + + +
+ + + + +
+ {#if statusCounts.online > 0} +
+ {/if} + {#if statusCounts.degraded > 0} +
+ {/if} + {#if statusCounts.offline > 0} +
+ {/if} + {#if statusCounts.unknown > 0} +
+ {/if} +
+ + +
+ {#if statusCounts.online > 0} + + + {statusCounts.online} online + + {/if} + {#if statusCounts.degraded > 0} + + + {statusCounts.degraded} degraded + + {/if} + {#if statusCounts.offline > 0} + + + {statusCounts.offline} offline + + {/if} + {#if statusCounts.unknown > 0} + + + {statusCounts.unknown} unknown + + {/if} +
+ + + {#if expanded} +
+ {#each matchedApps as app (app.id)} + {@const status = app.statuses[0]?.status ?? 'unknown'} + {@const statusColor = + status === 'online' + ? 'bg-green-500' + : status === 'offline' + ? 'bg-red-500' + : status === 'degraded' + ? 'bg-yellow-500' + : 'bg-gray-500'} +
+ {app.name} + + + {status} + +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index a5f1e69..712c0d7 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -1,45 +1,49 @@ {#if widgets.length === 0} -

No widgets in this section.

+

{$t('widget.no_widgets')}

{:else}
{#each widgets as widget (widget.id)} - - {#if widget.type === 'app' && widget.app} - - {:else} -
- {widget.type} widget -
- {/if} -
+ {@const isFullWidth = fullWidthTypes.has(widget.type)} +
+ + + +
{/each}
{/if} diff --git a/src/lib/components/widget/WidgetRenderer.svelte b/src/lib/components/widget/WidgetRenderer.svelte new file mode 100644 index 0000000..cba4cae --- /dev/null +++ b/src/lib/components/widget/WidgetRenderer.svelte @@ -0,0 +1,58 @@ + + +{#if widget.type === 'app' && widget.app} + +{:else if widget.type === 'bookmark'} + +{:else if widget.type === 'note'} + +{:else if widget.type === 'embed'} + +{:else if widget.type === 'status'} + +{:else} +
+ {$t('widget.type', { values: { type: widget.type } })} +
+{/if} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json new file mode 100644 index 0000000..5565929 --- /dev/null +++ b/src/lib/i18n/en.json @@ -0,0 +1,245 @@ +{ + "app_name": "App Launcher", + "app_title": "Web App Launcher", + + "nav.navigation": "Navigation", + "nav.boards": "Boards", + "nav.apps": "Apps", + "nav.admin": "Admin", + "nav.admin_panel": "Admin Panel", + + "auth.login": "Sign In", + "auth.login_title": "Welcome back", + "auth.login_subtitle": "Sign in to your account", + "auth.login_submit": "Sign In", + "auth.login_submitting": "Signing in...", + "auth.register": "Register", + "auth.register_title": "Create Account", + "auth.register_subtitle": "Get started with App Launcher", + "auth.register_submit": "Create Account", + "auth.register_submitting": "Creating account...", + "auth.email": "Email", + "auth.email_placeholder": "you@example.com", + "auth.password": "Password", + "auth.password_placeholder": "Enter your password", + "auth.password_placeholder_register": "At least 6 characters", + "auth.display_name": "Display Name", + "auth.display_name_placeholder": "Your name", + "auth.logout": "Sign Out", + "auth.oauth_signin": "Sign in with OAuth", + "auth.or": "or", + "auth.no_account": "Don't have an account?", + "auth.have_account": "Already have an account?", + "auth.sign_in_link": "Sign in", + + "board.title": "Boards", + "board.boards_available": "{count} board(s) available", + "board.new": "New Board", + "board.edit": "Edit", + "board.edit_board": "Edit Board", + "board.all_boards": "All Boards", + "board.back_to_boards": "Back to Boards", + "board.back_to_board": "Back to Board", + "board.no_boards": "No boards available.", + "board.sign_in_more": "Sign in to see more boards.", + "board.no_sections": "This board has no sections yet.", + "board.default": "Default", + "board.guest": "Guest", + "board.sections_count": "{count} section(s)", + "board.properties": "Board Properties", + "board.save": "Save Board", + "board.create": "Create Board", + "board.creating": "Creating...", + "board.default_board": "Default board", + "board.guest_accessible": "Guest accessible", + + "section.title_label": "Title", + "section.icon_label": "Icon", + "section.icon_placeholder": "Optional", + "section.sections": "Sections", + "section.add": "Add Section", + "section.create": "Create Section", + "section.order": "Order: {order}", + + "widget.add": "Add Widget", + "widget.select_app": "Select App", + "widget.choose_app": "Choose an app...", + "widget.no_widgets": "No widgets in this section.", + "widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.", + "widget.type": "{type} widget", + "widget.number": "Widget #{order}", + "widget.remove": "Remove", + + "app.title": "App Registry", + "app.apps_registered": "{count} app(s) registered", + "app.add": "Add App", + "app.new": "New App", + "app.no_apps": "No apps registered yet.", + "app.no_apps_hint": "Click \"Add App\" to register your first application.", + "app.all_categories": "All", + "app.name": "Name", + "app.name_placeholder": "My Application", + "app.url": "URL", + "app.url_placeholder": "https://my-app.local:8080", + "app.description": "Description", + "app.description_placeholder": "Brief description of this app", + "app.category": "Category", + "app.category_placeholder": "e.g. Media, Monitoring, Storage", + "app.tags": "Tags", + "app.tags_placeholder": "Comma-separated tags", + "app.icon": "Icon", + "app.icon_lucide": "Lucide Icon", + "app.icon_simple": "Simple Icons", + "app.icon_url": "Image URL", + "app.icon_emoji": "Emoji", + "app.icon_lucide_placeholder": "e.g. globe, server, home", + "app.icon_simple_placeholder": "e.g. github, docker", + "app.icon_url_placeholder": "https://example.com/icon.png", + "app.icon_emoji_placeholder": "e.g. \ud83c\udf10", + "app.icon_preview": "Icon preview", + "app.save": "Save App", + "app.saving": "Saving...", + "app.healthcheck_toggle": "Healthcheck Settings", + "app.healthcheck_show": "Show", + "app.healthcheck_hide": "Hide", + "app.healthcheck_enabled": "Enable Healthcheck", + "app.healthcheck_method": "Method", + "app.healthcheck_expected_status": "Expected Status", + "app.healthcheck_timeout": "Timeout (ms)", + "app.healthcheck_interval": "Interval (seconds)", + "app.icon_board_label": "Icon (Lucide name)", + + "admin.panel": "Admin Panel", + "admin.users": "Users", + "admin.groups": "Groups", + "admin.settings": "Settings", + + "admin.user_management": "User Management", + "admin.create_user": "Create User", + "admin.new_user": "New User", + "admin.user_column": "User", + "admin.email_column": "Email", + "admin.role_column": "Role", + "admin.provider_column": "Provider", + "admin.groups_column": "Groups", + "admin.actions_column": "Actions", + "admin.role_user": "User", + "admin.role_admin": "Admin", + "admin.select_group": "Select group", + "admin.add_to_group": "+ Add", + "admin.remove_from_group": "Remove from group", + "admin.no_users": "No users found.", + + "admin.group_management": "Group Management", + "admin.create_group": "Create Group", + "admin.new_group": "New Group", + "admin.name_column": "Name", + "admin.description_column": "Description", + "admin.members_column": "Members", + "admin.default_column": "Default", + "admin.default_group_hint": "Default group (auto-assign new users)", + "admin.no_groups": "No groups found.", + "admin.yes": "Yes", + "admin.no": "No", + + "admin.system_settings": "System Settings", + "admin.settings_description": "Configure global application settings.", + "admin.authentication": "Authentication", + "admin.auth_mode": "Auth Mode", + "admin.auth_local": "Local", + "admin.auth_oauth": "OAuth", + "admin.auth_both": "Both", + "admin.registration_enabled": "Allow user registration", + "admin.oauth_config": "OAuth Configuration", + "admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.", + "admin.oauth_client_id": "Client ID", + "admin.oauth_client_id_placeholder": "OAuth client ID", + "admin.oauth_client_secret": "Client Secret", + "admin.oauth_client_secret_placeholder": "OAuth client secret", + "admin.oauth_discovery_url": "Discovery URL", + "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", + "admin.oauth_test": "Test Connection", + "admin.oauth_testing": "Testing...", + "admin.oauth_connected": "Connected to issuer: {issuer}", + "admin.oauth_network_error": "Network error \u2014 could not reach the server", + "admin.theme_defaults": "Theme Defaults", + "admin.default_theme": "Default Theme", + "admin.default_primary_color": "Default Primary Color", + "admin.healthcheck_defaults": "Healthcheck Defaults", + "admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).", + "admin.healthcheck_defaults_label": "Defaults (JSON)", + "admin.save_settings": "Save Settings", + "admin.saving_settings": "Saving...", + + "admin.perm_title": "Grant Permission", + "admin.perm_entity_type": "Entity Type", + "admin.perm_entity": "Entity", + "admin.perm_target_type": "Target Type", + "admin.perm_target": "Target", + "admin.perm_level": "Level", + "admin.perm_board": "Board", + "admin.perm_app": "App", + "admin.perm_user": "User", + "admin.perm_group": "Group", + "admin.perm_view": "View", + "admin.perm_edit": "Edit", + "admin.perm_admin": "Admin", + "admin.perm_grant": "Grant", + "admin.perm_revoke": "Revoke", + "admin.perm_select": "Select...", + "admin.perm_entity_column": "Entity", + "admin.perm_target_column": "Target", + "admin.perm_level_column": "Level", + "admin.perm_action_column": "Action", + "admin.perm_none": "No permissions configured.", + + "search.placeholder": "Search apps and boards...", + "search.trigger": "Search...", + "search.min_chars": "Type at least 2 characters to search", + "search.no_results": "No results for \"{query}\"", + "search.apps": "Apps", + "search.boards": "Boards", + + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.create": "Create", + "common.back": "Back", + "common.edit": "Edit", + "common.add": "Add", + "common.confirm": "Confirm?", + "common.yes": "Yes", + "common.no": "No", + "common.name": "Name", + "common.description": "Description", + "common.required": "*", + + "status.online": "Online", + "status.offline": "Offline", + "status.degraded": "Degraded", + "status.unknown": "Unknown", + + "theme.dark": "Dark", + "theme.light": "Light", + "theme.system": "System", + "theme.toggle": "Toggle theme (current: {mode})", + "theme.title": "Theme: {mode}", + + "bg.mesh": "Mesh Gradient", + "bg.particles": "Particles", + "bg.aurora": "Aurora", + "bg.none": "None", + "bg.title": "Background effect", + "bg.aria_label": "Change background effect", + + "sidebar.expand": "Expand sidebar", + "sidebar.collapse": "Collapse sidebar", + "sidebar.toggle": "Toggle sidebar", + "sidebar.close": "Close sidebar", + + "home.welcome": "Welcome, {name}. No default board is configured yet.", + "home.view_boards": "View Boards", + "home.browse_apps": "Browse Apps", + + "language.label": "Language" +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..7c5fe4c --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,34 @@ +import { addMessages, init, getLocaleFromNavigator } from 'svelte-i18n'; +import en from './en.json'; +import ru from './ru.json'; + +const LOCALE_STORAGE_KEY = 'wal-locale'; + +addMessages('en', en); +addMessages('ru', ru); + +function getStoredLocale(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem(LOCALE_STORAGE_KEY); +} + +function detectLocale(): string { + const stored = getStoredLocale(); + if (stored && (stored === 'en' || stored === 'ru')) { + return stored; + } + + const browserLocale = getLocaleFromNavigator() ?? 'en'; + return browserLocale.startsWith('ru') ? 'ru' : 'en'; +} + +export function storeLocale(locale: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(LOCALE_STORAGE_KEY, locale); + } +} + +init({ + fallbackLocale: 'en', + initialLocale: detectLocale() +}); diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json new file mode 100644 index 0000000..b68cbff --- /dev/null +++ b/src/lib/i18n/ru.json @@ -0,0 +1,245 @@ +{ + "app_name": "App Launcher", + "app_title": "Web App Launcher", + + "nav.navigation": "\u041d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044f", + "nav.boards": "\u0414\u043e\u0441\u043a\u0438", + "nav.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "nav.admin": "\u0410\u0434\u043c\u0438\u043d", + "nav.admin_panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", + + "auth.login": "\u0412\u043e\u0439\u0442\u0438", + "auth.login_title": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c", + "auth.login_subtitle": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 \u0441\u0432\u043e\u0439 \u0430\u043a\u043a\u0430\u0443\u043d\u0442", + "auth.login_submit": "\u0412\u043e\u0439\u0442\u0438", + "auth.login_submitting": "\u0412\u0445\u043e\u0434...", + "auth.register": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f", + "auth.register_title": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442", + "auth.register_subtitle": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0440\u0430\u0431\u043e\u0442\u0443 \u0441 App Launcher", + "auth.register_submit": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442", + "auth.register_submitting": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430...", + "auth.email": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430", + "auth.email_placeholder": "you@example.com", + "auth.password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "auth.password_placeholder": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c", + "auth.password_placeholder_register": "\u041d\u0435 \u043c\u0435\u043d\u0435\u0435 6 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432", + "auth.display_name": "\u0418\u043c\u044f", + "auth.display_name_placeholder": "\u0412\u0430\u0448\u0435 \u0438\u043c\u044f", + "auth.logout": "\u0412\u044b\u0445\u043e\u0434", + "auth.oauth_signin": "\u0412\u043e\u0439\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 OAuth", + "auth.or": "\u0438\u043b\u0438", + "auth.no_account": "\u041d\u0435\u0442 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430?", + "auth.have_account": "\u0423\u0436\u0435 \u0435\u0441\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442?", + "auth.sign_in_link": "\u0412\u043e\u0439\u0442\u0438", + + "board.title": "\u0414\u043e\u0441\u043a\u0438", + "board.boards_available": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0434\u043e\u0441\u043e\u043a: {count}", + "board.new": "\u041d\u043e\u0432\u0430\u044f \u0434\u043e\u0441\u043a\u0430", + "board.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c", + "board.edit_board": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0434\u043e\u0441\u043a\u0438", + "board.all_boards": "\u0412\u0441\u0435 \u0434\u043e\u0441\u043a\u0438", + "board.back_to_boards": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0430\u043c", + "board.back_to_board": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0435", + "board.no_boards": "\u0414\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "board.sign_in_more": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0432\u0438\u0434\u0435\u0442\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u043e\u0441\u043e\u043a.", + "board.no_sections": "\u041d\u0430 \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0435 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432.", + "board.default": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "board.guest": "\u0413\u043e\u0441\u0442\u0435\u0432\u0430\u044f", + "board.sections_count": "\u0420\u0430\u0437\u0434\u0435\u043b\u043e\u0432: {count}", + "board.properties": "\u0421\u0432\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u0441\u043a\u0438", + "board.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0434\u043e\u0441\u043a\u0443", + "board.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443", + "board.creating": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435...", + "board.default_board": "\u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "board.guest_accessible": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0433\u043e\u0441\u0442\u044f\u043c", + + "section.title_label": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a", + "section.icon_label": "\u0418\u043a\u043e\u043d\u043a\u0430", + "section.icon_placeholder": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e", + "section.sections": "\u0420\u0430\u0437\u0434\u0435\u043b\u044b", + "section.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b", + "section.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b", + "section.order": "\u041f\u043e\u0440\u044f\u0434\u043e\u043a: {order}", + + "widget.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432\u0438\u0434\u0436\u0435\u0442", + "widget.select_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "widget.choose_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435...", + "widget.no_widgets": "\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432.", + "widget.no_widgets_dnd": "\u041d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432. \u041f\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0441\u044e\u0434\u0430 \u0438\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0432\u044b\u0448\u0435.", + "widget.type": "\u0412\u0438\u0434\u0436\u0435\u0442 {type}", + "widget.number": "\u0412\u0438\u0434\u0436\u0435\u0442 #{order}", + "widget.remove": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", + + "app.title": "\u0420\u0435\u0435\u0441\u0442\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", + "app.apps_registered": "\u0417\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439: {count}", + "app.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "app.new": "\u041d\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "app.no_apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0435\u0449\u0451 \u043d\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b.", + "app.no_apps_hint": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u00bb, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435.", + "app.all_categories": "\u0412\u0441\u0435", + "app.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "app.name_placeholder": "\u041c\u043e\u0451 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "app.url": "URL", + "app.url_placeholder": "https://my-app.local:8080", + "app.description": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "app.description_placeholder": "\u041a\u0440\u0430\u0442\u043a\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "app.category": "\u041a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f", + "app.category_placeholder": "\u043d\u0430\u043f\u0440. \u041c\u0435\u0434\u0438\u0430, \u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433, \u0425\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435", + "app.tags": "\u0422\u0435\u0433\u0438", + "app.tags_placeholder": "\u0422\u0435\u0433\u0438 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e", + "app.icon": "\u0418\u043a\u043e\u043d\u043a\u0430", + "app.icon_lucide": "Lucide", + "app.icon_simple": "Simple Icons", + "app.icon_url": "URL \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f", + "app.icon_emoji": "\u042d\u043c\u043e\u0434\u0437\u0438", + "app.icon_lucide_placeholder": "\u043d\u0430\u043f\u0440. globe, server, home", + "app.icon_simple_placeholder": "\u043d\u0430\u043f\u0440. github, docker", + "app.icon_url_placeholder": "https://example.com/icon.png", + "app.icon_emoji_placeholder": "\u043d\u0430\u043f\u0440. \ud83c\udf10", + "app.icon_preview": "\u041f\u0440\u0435\u0432\u044c\u044e \u0438\u043a\u043e\u043d\u043a\u0438", + "app.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c", + "app.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", + "app.healthcheck_toggle": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", + "app.healthcheck_show": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c", + "app.healthcheck_hide": "\u0421\u043a\u0440\u044b\u0442\u044c", + "app.healthcheck_enabled": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", + "app.healthcheck_method": "\u041c\u0435\u0442\u043e\u0434", + "app.healthcheck_expected_status": "\u041e\u0436\u0438\u0434\u0430\u0435\u043c\u044b\u0439 \u0441\u0442\u0430\u0442\u0443\u0441", + "app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)", + "app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)", + "app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)", + + "admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", + "admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", + "admin.groups": "\u0413\u0440\u0443\u043f\u043f\u044b", + "admin.settings": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + + "admin.user_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c\u0438", + "admin.create_user": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", + "admin.new_user": "\u041d\u043e\u0432\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", + "admin.user_column": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", + "admin.email_column": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430", + "admin.role_column": "\u0420\u043e\u043b\u044c", + "admin.provider_column": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440", + "admin.groups_column": "\u0413\u0440\u0443\u043f\u043f\u044b", + "admin.actions_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044f", + "admin.role_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", + "admin.role_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440", + "admin.select_group": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443", + "admin.add_to_group": "+ \u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "admin.remove_from_group": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0438\u0437 \u0433\u0440\u0443\u043f\u043f\u044b", + "admin.no_users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + + "admin.group_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u0438", + "admin.create_group": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443", + "admin.new_group": "\u041d\u043e\u0432\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430", + "admin.name_column": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "admin.description_column": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "admin.members_column": "\u0423\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438", + "admin.default_column": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "admin.default_group_hint": "\u0413\u0440\u0443\u043f\u043f\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0430\u0432\u0442\u043e-\u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u043e\u0432\u044b\u043c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c)", + "admin.no_groups": "\u0413\u0440\u0443\u043f\u043f\u044b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "admin.yes": "\u0414\u0430", + "admin.no": "\u041d\u0435\u0442", + + "admin.system_settings": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "admin.settings_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0433\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", + "admin.authentication": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", + "admin.auth_mode": "\u0420\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438", + "admin.auth_local": "\u041b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439", + "admin.auth_oauth": "OAuth", + "admin.auth_both": "\u041e\u0431\u0430", + "admin.registration_enabled": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439", + "admin.oauth_config": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 OAuth", + "admin.oauth_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 OIDC (\u043d\u0430\u043f\u0440. Authentik, Keycloak). \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0440\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u00abOAuth\u00bb \u0438\u043b\u0438 \u00ab\u041e\u0431\u0430\u00bb \u0432\u044b\u0448\u0435, \u0447\u0442\u043e\u0431\u044b \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0447\u0435\u0440\u0435\u0437 OAuth.", + "admin.oauth_client_id": "Client ID", + "admin.oauth_client_id_placeholder": "OAuth client ID", + "admin.oauth_client_secret": "\u0421\u0435\u043a\u0440\u0435\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "admin.oauth_client_secret_placeholder": "\u0421\u0435\u043a\u0440\u0435\u0442 OAuth \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "admin.oauth_discovery_url": "Discovery URL", + "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", + "admin.oauth_test": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "admin.oauth_testing": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435...", + "admin.oauth_connected": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a: {issuer}", + "admin.oauth_network_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0442\u0438 \u2014 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0441\u0432\u044f\u0437\u0430\u0442\u044c\u0441\u044f \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u043c", + "admin.theme_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0442\u0435\u043c\u044b", + "admin.default_theme": "\u0422\u0435\u043c\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "admin.default_primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "admin.healthcheck_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", + "admin.healthcheck_defaults_description": "JSON-\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b, \u0442\u0430\u0439\u043c\u0430\u0443\u0442, \u043c\u0435\u0442\u043e\u0434).", + "admin.healthcheck_defaults_label": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 (JSON)", + "admin.save_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "admin.saving_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", + + "admin.perm_title": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u043f\u0440\u0430\u0432\u0430", + "admin.perm_entity_type": "\u0422\u0438\u043f \u043e\u0431\u044a\u0435\u043a\u0442\u0430", + "admin.perm_entity": "\u041e\u0431\u044a\u0435\u043a\u0442", + "admin.perm_target_type": "\u0422\u0438\u043f \u0446\u0435\u043b\u0438", + "admin.perm_target": "\u0426\u0435\u043b\u044c", + "admin.perm_level": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c", + "admin.perm_board": "\u0414\u043e\u0441\u043a\u0430", + "admin.perm_app": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "admin.perm_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", + "admin.perm_group": "\u0413\u0440\u0443\u043f\u043f\u0430", + "admin.perm_view": "\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440", + "admin.perm_edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435", + "admin.perm_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440", + "admin.perm_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c", + "admin.perm_revoke": "\u041e\u0442\u043e\u0437\u0432\u0430\u0442\u044c", + "admin.perm_select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c...", + "admin.perm_entity_column": "\u041e\u0431\u044a\u0435\u043a\u0442", + "admin.perm_target_column": "\u0426\u0435\u043b\u044c", + "admin.perm_level_column": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c", + "admin.perm_action_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435", + "admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", + + "search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...", + "search.trigger": "\u041f\u043e\u0438\u0441\u043a...", + "search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430", + "search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb", + "search.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "search.boards": "\u0414\u043e\u0441\u043a\u0438", + + "common.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c", + "common.cancel": "\u041e\u0442\u043c\u0435\u043d\u0430", + "common.delete": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", + "common.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c", + "common.back": "\u041d\u0430\u0437\u0430\u0434", + "common.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c", + "common.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "common.confirm": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c?", + "common.yes": "\u0414\u0430", + "common.no": "\u041d\u0435\u0442", + "common.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "common.description": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "common.required": "*", + + "status.online": "\u041e\u043d\u043b\u0430\u0439\u043d", + "status.offline": "\u041e\u0444\u0444\u043b\u0430\u0439\u043d", + "status.degraded": "\u041d\u0435\u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u043e", + "status.unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e", + + "theme.dark": "\u0422\u0451\u043c\u043d\u0430\u044f", + "theme.light": "\u0421\u0432\u0435\u0442\u043b\u0430\u044f", + "theme.system": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f", + "theme.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0442\u0435\u043c\u0443 (\u0442\u0435\u043a\u0443\u0449\u0430\u044f: {mode})", + "theme.title": "\u0422\u0435\u043c\u0430: {mode}", + + "bg.mesh": "\u041c\u0435\u0448-\u0433\u0440\u0430\u0434\u0438\u0435\u043d\u0442", + "bg.particles": "\u0427\u0430\u0441\u0442\u0438\u0446\u044b", + "bg.aurora": "\u0421\u0438\u044f\u043d\u0438\u0435", + "bg.none": "\u041d\u0435\u0442", + "bg.title": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", + "bg.aria_label": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u044d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", + + "sidebar.expand": "\u0420\u0430\u0437\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", + "sidebar.collapse": "\u0421\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", + "sidebar.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", + "sidebar.close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", + + "home.welcome": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, {name}. \u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0435\u0449\u0451 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438", + "home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", + + "language.label": "\u042f\u0437\u044b\u043a" +} diff --git a/src/lib/types/widget.ts b/src/lib/types/widget.ts index 4b22035..b77224f 100644 --- a/src/lib/types/widget.ts +++ b/src/lib/types/widget.ts @@ -29,27 +29,27 @@ export interface UpdateWidgetInput { // Typed config shapes for different widget types export interface AppWidgetConfig { readonly appId: string; - readonly showStatus?: boolean; - readonly openInNewTab?: boolean; } export interface BookmarkWidgetConfig { readonly url: string; - readonly title: string; + readonly label: string; readonly icon?: string; - readonly openInNewTab?: boolean; + readonly description?: string; } export interface NoteWidgetConfig { readonly content: string; + readonly format: 'markdown' | 'text'; } export interface EmbedWidgetConfig { readonly url: string; - readonly height?: number; + readonly height: number; + readonly sandbox?: string; } export interface StatusWidgetConfig { readonly appIds: readonly string[]; - readonly layout?: 'grid' | 'list'; + readonly label?: string; } diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index 2686743..d6de82d 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -123,6 +123,35 @@ export const updateSectionSchema = z.object({ isExpandedByDefault: z.boolean().optional() }); +// --- Widget Config Schemas --- + +export const appWidgetConfigSchema = z.object({ + appId: z.string().min(1, 'App ID is required') +}); + +export const bookmarkWidgetConfigSchema = z.object({ + url: z.string().url('Invalid URL'), + label: z.string().min(1, 'Label is required').max(200), + icon: z.string().max(100).optional(), + description: z.string().max(500).optional() +}); + +export const noteWidgetConfigSchema = z.object({ + content: z.string().max(10000, 'Content too long'), + format: z.enum(['markdown', 'text']).default('markdown') +}); + +export const embedWidgetConfigSchema = z.object({ + url: z.string().url('Invalid URL'), + height: z.number().int().min(100).max(2000).default(300), + sandbox: z.string().max(200).optional() +}); + +export const statusWidgetConfigSchema = z.object({ + appIds: z.array(z.string().min(1)).min(1, 'At least one app is required'), + label: z.string().max(200).optional() +}); + // --- Widget --- export const createWidgetSchema = z.object({ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 61cadd3..6da0017 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,6 @@ - Web App Launcher + {$t('app_title')}
-

Web App Launcher

+

{$t('app_title')}

{#if data.user}

- Welcome, {data.user.displayName}. No default board is configured yet. + {$t('home.welcome', { values: { name: data.user.displayName } })}

{/if} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 3dde6d9..cf3e33e 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,15 +1,16 @@ - Group Management — Admin + {$t('admin.group_management')} — {$t('admin.panel')}
-

Group Management

+

{$t('admin.group_management')}

{#if showCreateForm}
-

New Group

+

{$t('admin.new_group')}

- + {$errors.name}{/if}
- + - +
{#if $errors._errors} @@ -78,7 +79,7 @@ type="submit" class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" > - Create Group + {$t('admin.create_group')}
diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 8374dd5..1984d27 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -1,4 +1,5 @@ - System Settings — Admin + {$t('admin.system_settings')} — {$t('admin.panel')}
-

System Settings

-

Configure global application settings.

+

{$t('admin.system_settings')}

+

{$t('admin.settings_description')}

diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte index 5ff487c..7473510 100644 --- a/src/routes/admin/users/+page.svelte +++ b/src/routes/admin/users/+page.svelte @@ -1,4 +1,5 @@ - User Management — Admin + {$t('admin.user_management')} — {$t('admin.panel')}
-

User Management

+

{$t('admin.user_management')}

{#if showCreateForm}
-

New User

+

{$t('admin.new_user')}

- + {$errors.email}{/if}
- + {$errors.displayName}{/if}
- + {$errors.password}{/if}
- +
@@ -93,7 +94,7 @@ type="submit" class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" > - Create User + {$t('admin.create_user')}
diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte index 9529e93..b9315df 100644 --- a/src/routes/apps/+page.svelte +++ b/src/routes/apps/+page.svelte @@ -1,4 +1,5 @@ - Apps — Web App Launcher + {$t('app.title')} — {$t('app_title')}
-

App Registry

+

{$t('app.title')}

- {data.apps.length} app{data.apps.length === 1 ? '' : 's'} registered + {$t('app.apps_registered', { values: { count: data.apps.length } })}

{#if showForm}
-

New App

+

{$t('app.new')}

{/if} @@ -43,7 +44,7 @@ href="/apps" class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" > - All + {$t('app.all_categories')} {#each data.categories as category (category)} -

No apps registered yet.

-

Click "Add App" to register your first application.

+

{$t('app.no_apps')}

+

{$t('app.no_apps_hint')}

{:else}
diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index bea5566..f8d7565 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -1,4 +1,5 @@ - Boards — Web App Launcher + {$t('board.title')} — {$t('app_title')}
{:else} diff --git a/src/routes/boards/[boardId]/+page.server.ts b/src/routes/boards/[boardId]/+page.server.ts index c93ecba..d70ca40 100644 --- a/src/routes/boards/[boardId]/+page.server.ts +++ b/src/routes/boards/[boardId]/+page.server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types.js'; import * as boardService from '$lib/server/services/boardService.js'; +import * as appService from '$lib/server/services/appService.js'; import * as permissionService from '$lib/server/services/permissionService.js'; import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; @@ -32,7 +33,10 @@ export const load: PageServerLoad = async ({ params, locals }) => { try { // findBoardById includes sections -> widgets -> app -> statuses - const board = await boardService.findBoardById(boardId); + const [board, allApps] = await Promise.all([ + boardService.findBoardById(boardId), + appService.findAll() + ]); // Determine if user can edit this board let canEdit = false; @@ -50,7 +54,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { } } - return { board, canEdit }; + return { board, canEdit, allApps }; } catch (err) { const message = err instanceof Error ? err.message : 'Board not found'; if (message.includes('not found')) { diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 0848fa7..0d4426a 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -1,4 +1,5 @@ - {data.board.name} — Web App Launcher + {data.board.name} — {$t('app_title')}
@@ -20,6 +21,6 @@ canEdit={data.canEdit} /> - +
diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts index 9bf4563..fd7f872 100644 --- a/src/routes/boards/[boardId]/edit/+page.server.ts +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -152,16 +152,25 @@ export const actions: Actions = { requireAuth(event); const formData = await event.request.formData(); const sectionId = formData.get('sectionId') as string; - const appId = (formData.get('appId') as string) || undefined; const type = (formData.get('type') as string) || 'app'; + const appId = (formData.get('appId') as string) || undefined; + const configJson = (formData.get('configJson') as string) || undefined; - const config = appId ? JSON.stringify({ appId }) : '{}'; + // Build config based on widget type + let config: string; + if (type === 'app' && appId) { + config = JSON.stringify({ appId }); + } else if (configJson) { + config = configJson; + } else { + config = '{}'; + } const data = { sectionId, type, config, - appId + appId: type === 'app' ? appId : undefined }; const parsed = createWidgetSchema.safeParse(data); diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 6aea0b9..e645204 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -1,8 +1,10 @@ - Edit: {data.board.name} + {$t('board.edit_board')}: {data.board.name}
-

Edit Board

+

{$t('board.edit_board')}

- Back to Board + {$t('board.back_to_board')}
-

Board Properties

+

{$t('board.properties')}

- +
- +
- + -
-
- {:else if selectedWidgetType === 'embed'} -
-
- - -
-
- - -
-
- {:else if selectedWidgetType === 'status'} -
-
- - -
-
- Select Apps -
- {#each apps as app (app.id)} - - {/each} -
- {#if statusAppIds.length > 0} -

{statusAppIds.length} app(s) selected

- {/if} -
-
- {/if} - -
- -
-
+ {/if} diff --git a/src/lib/components/widget/WidgetCreationForm.svelte b/src/lib/components/widget/WidgetCreationForm.svelte new file mode 100644 index 0000000..532c402 --- /dev/null +++ b/src/lib/components/widget/WidgetCreationForm.svelte @@ -0,0 +1,270 @@ + + +
+ +
+ + +
+ + + {#if selectedWidgetType === 'app'} +
+ + +
+ {:else if selectedWidgetType === 'bookmark'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'note'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'embed'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'status'} +
+
+ + +
+
+ Select Apps +
+ {#each apps as app (app.id)} + + {/each} +
+ {#if statusAppIds.length > 0} +

{statusAppIds.length} app(s) selected

+ {/if} +
+
+ {/if} + +
+ +
+
diff --git a/src/lib/server/services/__tests__/oauthService.test.ts b/src/lib/server/services/__tests__/oauthService.test.ts index 2a17a4b..c56d504 100644 --- a/src/lib/server/services/__tests__/oauthService.test.ts +++ b/src/lib/server/services/__tests__/oauthService.test.ts @@ -25,6 +25,7 @@ import { prisma } from '../../prisma.js'; import { invalidateOAuthCache, generateCodeVerifier, + generateState, calculateCodeChallenge, generateAuthUrl, handleCallback, @@ -69,6 +70,14 @@ describe('oauthService', () => { }); }); + describe('generateState', () => { + it('returns a random state string', () => { + const state = generateState(); + expect(state).toBe('mock-state-123'); + expect(mockClient.randomState).toHaveBeenCalledOnce(); + }); + }); + describe('calculateCodeChallenge', () => { it('returns a PKCE code challenge', async () => { const challenge = await calculateCodeChallenge('my-verifier'); @@ -86,7 +95,7 @@ describe('oauthService', () => { new URL('https://auth.example.com/authorize?code_challenge=abc') ); - const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge'); + const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'test-state'); expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc'); expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith( @@ -95,7 +104,8 @@ describe('oauthService', () => { redirect_uri: 'https://app.example.com/callback', scope: 'openid profile email', code_challenge: 'test-challenge', - code_challenge_method: 'S256' + code_challenge_method: 'S256', + state: 'test-state' }) ); }); @@ -111,7 +121,7 @@ describe('oauthService', () => { delete process.env.OAUTH_DISCOVERY_URL; await expect( - generateAuthUrl('https://app.example.com/callback', 'challenge') + generateAuthUrl('https://app.example.com/callback', 'challenge', 'state') ).rejects.toThrow('OAuth is not configured'); // Restore @@ -120,25 +130,20 @@ describe('oauthService', () => { process.env.OAUTH_DISCOVERY_URL = origDiscovery; }); - it('adds state when provider does not support PKCE', async () => { + it('always includes the state parameter', async () => { setupOAuthSettings(); - const mockConfig = { - serverMetadata: () => ({ - issuer: 'https://auth.example.com', - supportsPKCE: () => false - }) - }; + const mockConfig = createMockOIDCConfig(); mockClient.discovery.mockResolvedValue(mockConfig); mockClient.buildAuthorizationUrl.mockReturnValue( new URL('https://auth.example.com/authorize') ); - await generateAuthUrl('https://app.example.com/callback', 'test-challenge'); + await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'custom-state'); expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ - state: 'mock-state-123' + state: 'custom-state' }) ); }); @@ -163,8 +168,9 @@ describe('oauthService', () => { }); const result = await handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ); expect(result).toEqual({ @@ -188,8 +194,9 @@ describe('oauthService', () => { await expect( handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ) ).rejects.toThrow('subject claim'); }); @@ -209,8 +216,9 @@ describe('oauthService', () => { await expect( handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ) ).rejects.toThrow('email'); }); diff --git a/src/lib/server/services/oauthService.ts b/src/lib/server/services/oauthService.ts index ddac94f..27d3b69 100644 --- a/src/lib/server/services/oauthService.ts +++ b/src/lib/server/services/oauthService.ts @@ -96,6 +96,13 @@ export function generateCodeVerifier(): string { return client.randomPKCECodeVerifier(); } +/** + * Generates a cryptographically random state parameter. + */ +export function generateState(): string { + return client.randomState(); +} + /** * Calculates the PKCE code_challenge from a code_verifier. */ @@ -105,10 +112,12 @@ export async function calculateCodeChallenge(codeVerifier: string): Promise { const config = await getOIDCConfig(); @@ -116,14 +125,10 @@ export async function generateAuthUrl( redirect_uri: redirectUri, scope: 'openid profile email', code_challenge: codeChallenge, - code_challenge_method: 'S256' + code_challenge_method: 'S256', + state }; - // Add state if the server might not support PKCE - if (!config.serverMetadata().supportsPKCE()) { - parameters.state = client.randomState(); - } - const url = client.buildAuthorizationUrl(config, parameters); return url.href; } @@ -133,12 +138,14 @@ export async function generateAuthUrl( */ export async function handleCallback( callbackUrl: URL, - codeVerifier: string + codeVerifier: string, + expectedState: string ): Promise { const config = await getOIDCConfig(); const tokens = await client.authorizationCodeGrant(config, callbackUrl, { - pkceCodeVerifier: codeVerifier + pkceCodeVerifier: codeVerifier, + expectedState }); // Try to get user info from the userinfo endpoint diff --git a/src/lib/server/services/userService.ts b/src/lib/server/services/userService.ts index 7946667..2416347 100644 --- a/src/lib/server/services/userService.ts +++ b/src/lib/server/services/userService.ts @@ -184,14 +184,16 @@ async function syncOAuthGroups(userId: string, oauthGroupNames: readonly string[ return; } - // Upsert memberships (idempotent — won't fail if already a member) - for (const group of matchingGroups) { - await prisma.userGroup.upsert({ - where: { - userId_groupId: { userId, groupId: group.id } - }, - update: {}, - create: { userId, groupId: group.id } - }); - } + // Upsert memberships in a single transaction (idempotent — won't fail if already a member) + await prisma.$transaction( + matchingGroups.map((group) => + prisma.userGroup.upsert({ + where: { + userId_groupId: { userId, groupId: group.id } + }, + update: {}, + create: { userId, groupId: group.id } + }) + ) + ); } diff --git a/src/lib/utils/boardPermissions.ts b/src/lib/utils/boardPermissions.ts new file mode 100644 index 0000000..0d418a0 --- /dev/null +++ b/src/lib/utils/boardPermissions.ts @@ -0,0 +1,86 @@ +import { TargetType } from './constants.js'; + +export interface PermissionRecord { + id: string; + entityType: string; + entityId: string; + targetType: string; + targetId: string; + level: string; + createdAt: string; +} + +export interface SelectOption { + id: string; + name: string; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * Fetches the permission records for a board. + */ +export async function loadBoardPermissions(boardId: string): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`); + const json: ApiResponse = await res.json(); + if (json.success && json.data) { + return json.data; + } + throw new Error(json.error ?? 'Failed to load permissions'); +} + +/** + * Grants a permission on a board to a user or group. + */ +export async function grantBoardPermission( + boardId: string, + targetType: string, + targetId: string, + level: string +): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType, targetId, level }) + }); + const json: ApiResponse = await res.json(); + if (!json.success) { + throw new Error(json.error ?? 'Failed to grant permission'); + } +} + +/** + * Revokes a permission on a board for a user or group. + */ +export async function revokeBoardPermission( + boardId: string, + targetType: string, + targetId: string +): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType, targetId }) + }); + const json: ApiResponse = await res.json(); + if (!json.success) { + throw new Error(json.error ?? 'Failed to revoke permission'); + } +} + +/** + * Resolves a target (user or group) ID to a display name. + */ +export function getTargetName( + targetType: string, + targetId: string, + users: SelectOption[], + groups: SelectOption[] +): string { + const list = targetType === TargetType.USER ? users : groups; + return list.find((item) => item.id === targetId)?.name ?? targetId; +} diff --git a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts index c44568f..06a7be6 100644 --- a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts +++ b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts @@ -37,8 +37,8 @@ export const PUT: RequestHandler = async (event) => { } const { widgetIds } = body as { widgetIds?: string[] }; - if (!Array.isArray(widgetIds)) { - return json(error('widgetIds must be an array of strings'), { status: 400 }); + if (!Array.isArray(widgetIds) || widgetIds.length === 0) { + return json(error('widgetIds must be a non-empty array of strings'), { status: 400 }); } if (!widgetIds.every((wid) => typeof wid === 'string')) { diff --git a/src/routes/auth/oauth/authorize/+server.ts b/src/routes/auth/oauth/authorize/+server.ts index 22071a6..8e34972 100644 --- a/src/routes/auth/oauth/authorize/+server.ts +++ b/src/routes/auth/oauth/authorize/+server.ts @@ -14,18 +14,23 @@ export const GET: RequestHandler = async ({ cookies, url }) => { const appUrl = process.env.APP_URL || url.origin; const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`; - // Generate PKCE values + // Generate PKCE values and state parameter const codeVerifier = oauthService.generateCodeVerifier(); const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier); + const state = oauthService.generateState(); - // Store code_verifier in HTTP-only cookie for the callback + // Store code_verifier and state in HTTP-only cookies for the callback cookies.set('oauth_code_verifier', codeVerifier, { ...COOKIE_BASE, maxAge: 600 // 10 minutes — enough for the auth flow }); + cookies.set('oauth_state', state, { + ...COOKIE_BASE, + maxAge: 600 + }); // Build authorization URL and redirect - const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge); + const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge, state); throw redirect(302, authUrl); } catch (err) { diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts index 489e9de..14212ae 100644 --- a/src/routes/auth/oauth/callback/+server.ts +++ b/src/routes/auth/oauth/callback/+server.ts @@ -26,17 +26,29 @@ export const GET: RequestHandler = async ({ url, cookies }) => { throw new Error('No authorization code received from OAuth provider'); } - // Retrieve the code_verifier from the cookie + // Retrieve the code_verifier and state from cookies const codeVerifier = cookies.get('oauth_code_verifier'); if (!codeVerifier) { throw new Error('OAuth session expired. Please try logging in again.'); } - // Clear the code_verifier cookie + const expectedState = cookies.get('oauth_state'); + if (!expectedState) { + throw new Error('OAuth session expired. Please try logging in again.'); + } + + // Validate the state parameter matches to prevent CSRF + const returnedState = url.searchParams.get('state'); + if (returnedState !== expectedState) { + throw new Error('OAuth state mismatch. Possible CSRF attack. Please try logging in again.'); + } + + // Clear the OAuth cookies cookies.delete('oauth_code_verifier', { path: '/' }); + cookies.delete('oauth_state', { path: '/' }); // Exchange the authorization code for tokens and get user info - const userInfo = await oauthService.handleCallback(url, codeVerifier); + const userInfo = await oauthService.handleCallback(url, codeVerifier, expectedState); // Find or create local user from OAuth info const user = await userService.findOrCreateByOAuth({ diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 43849a1..3f7a62e 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -1,6 +1,7 @@ @@ -37,6 +45,10 @@ onShare={() => { showShareDialog = true; }} /> + {#if guestToggleError} +

{guestToggleError}

+ {/if} +
diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts index 0dd503e..5044263 100644 --- a/src/routes/boards/[boardId]/edit/+page.server.ts +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -6,12 +6,17 @@ import * as permissionService from '$lib/server/services/permissionService.js'; import * as userService from '$lib/server/services/userService.js'; import * as groupService from '$lib/server/services/groupService.js'; import { requireAuth } from '$lib/server/middleware/authenticate.js'; -import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { EntityType, PermissionLevel, UserRole, WidgetType } from '$lib/utils/constants.js'; import { updateBoardSchema, createSectionSchema, updateSectionSchema, - createWidgetSchema + createWidgetSchema, + appWidgetConfigSchema, + bookmarkWidgetConfigSchema, + noteWidgetConfigSchema, + embedWidgetConfigSchema, + statusWidgetConfigSchema } from '$lib/utils/validators.js'; export const load: PageServerLoad = async (event) => { @@ -214,6 +219,35 @@ export const actions: Actions = { return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') }; } + // Validate config JSON against the type-specific schema + if (config && config !== '{}') { + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(config); + } catch { + return { success: false, error: 'Invalid config JSON' }; + } + + const configSchemaMap = { + [WidgetType.APP]: appWidgetConfigSchema, + [WidgetType.BOOKMARK]: bookmarkWidgetConfigSchema, + [WidgetType.NOTE]: noteWidgetConfigSchema, + [WidgetType.EMBED]: embedWidgetConfigSchema, + [WidgetType.STATUS]: statusWidgetConfigSchema + } as const; + + const configSchema = configSchemaMap[type as keyof typeof configSchemaMap]; + if (configSchema) { + const configResult = configSchema.safeParse(parsedConfig); + if (!configResult.success) { + return { + success: false, + error: configResult.error.errors.map((e) => e.message).join(', ') + }; + } + } + } + try { await boardService.createWidget(parsed.data); return { success: true }; diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 80a89d8..5ad040c 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -11,6 +11,7 @@ let showAddSection = $state(false); let addWidgetSectionId = $state(null); + let errorMessage = $state(''); function handleToggleAddWidget(sectionId: string) { addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId; @@ -27,7 +28,7 @@ }); await invalidateAll(); } catch (err) { - console.error('Failed to delete section:', err); + errorMessage = err instanceof Error ? err.message : 'Failed to delete section'; } } @@ -80,7 +81,7 @@ addWidgetSectionId = null; await invalidateAll(); } catch (err) { - console.error('Failed to add widget:', err); + errorMessage = err instanceof Error ? err.message : 'Failed to add widget'; } } @@ -95,7 +96,7 @@ }); await invalidateAll(); } catch (err) { - console.error('Failed to delete widget:', err); + errorMessage = err instanceof Error ? err.message : 'Failed to delete widget'; } } @@ -106,6 +107,15 @@
+ {#if errorMessage} +
+

{errorMessage}

+ +
+ {/if} +

{$t('board.edit_board')}