From 5bb4fbcedf097c73c8c63d30cc1e0b06876b8a0c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 23:29:19 +0300 Subject: [PATCH] feat(phase2): per-board access control UI - BoardAccessControl component with user/group autocomplete - BoardShareDialog modal with copy link, guest toggle, quick add - Board permissions REST API (GET/POST/DELETE) - Access indicators on BoardCard (lock, globe, shared icons) - Guest access toggle in board editor with status preview - Enhanced PermissionEditor with search autocomplete - i18n translations for all new strings (EN/RU) --- plans/phase-2-enhanced-features/CONTEXT.md | 16 + plans/phase-2-enhanced-features/PLAN.md | 2 +- .../phase-5-access-control.md | 43 ++- .../components/admin/PermissionEditor.svelte | 110 ++++-- .../board/BoardAccessControl.svelte | 270 ++++++++++++++ src/lib/components/board/BoardCard.svelte | 25 +- src/lib/components/board/BoardHeader.svelte | 19 +- .../components/board/BoardShareDialog.svelte | 332 ++++++++++++++++++ src/lib/i18n/en.json | 20 ++ src/lib/i18n/ru.json | 20 ++ .../api/boards/[id]/permissions/+server.ts | 175 +++++++++ src/routes/boards/+page.server.ts | 26 +- src/routes/boards/[boardId]/+page.server.ts | 23 +- src/routes/boards/[boardId]/+page.svelte | 28 ++ .../boards/[boardId]/edit/+page.server.ts | 42 ++- src/routes/boards/[boardId]/edit/+page.svelte | 72 +++- 16 files changed, 1166 insertions(+), 57 deletions(-) create mode 100644 src/lib/components/board/BoardAccessControl.svelte create mode 100644 src/lib/components/board/BoardShareDialog.svelte create mode 100644 src/routes/api/boards/[id]/permissions/+server.ts diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md index bb1eaa0..f3e3e01 100644 --- a/plans/phase-2-enhanced-features/CONTEXT.md +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -61,3 +61,19 @@ Admin settings page has a working "Test Connection" button for OAuth configurati - 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 + +## Phase 5 (Per-Board Access Control UI) — Completed + +- Created `src/lib/components/board/BoardAccessControl.svelte` — self-contained board permission manager with search/autocomplete for users and groups, fetches permissions from `/api/boards/[id]/permissions` +- Created `src/lib/components/board/BoardShareDialog.svelte` — modal dialog with copy link, guest access toggle, quick permission grant, and current access list +- Created `src/routes/api/boards/[id]/permissions/+server.ts` — REST endpoint for GET (list), POST (grant), DELETE (revoke) board permissions with proper auth checks +- Enhanced `src/lib/components/admin/PermissionEditor.svelte` — replaced plain select dropdowns with search/autocomplete inputs (onfocus/onblur managed dropdowns) +- Updated `src/lib/components/board/BoardCard.svelte` — added globe icon for guest-accessible boards, lock icon for private boards, users icon for boards with shared permissions +- Updated `src/routes/boards/+page.server.ts` — computes `hasSharedPermissions` flag per board for access indicators +- Updated `src/routes/boards/[boardId]/edit/+page.svelte` — added dedicated "Guest Access" section with status preview and "Permissions" section with `BoardAccessControl` component +- Updated `src/routes/boards/[boardId]/edit/+page.server.ts` — loads users and groups for permission editor, computes `canManagePermissions` flag +- Updated `src/lib/components/board/BoardHeader.svelte` — added "Share" button that triggers share dialog callback +- Updated `src/routes/boards/[boardId]/+page.svelte` — integrated `BoardShareDialog` with guest toggle via PATCH API +- Updated `src/routes/boards/[boardId]/+page.server.ts` — loads users/groups for share dialog when user can edit +- Added ~20 new i18n keys (`board.access_*`, `board.share_*`, `board.guest_access_*`, `board.permissions_*`, `admin.perm_search_placeholder`) to both `en.json` and `ru.json` +- Big Bang strategy: no build/test verification performed — Phase 6 integration may be needed diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md index e0b852e..04da817 100644 --- a/plans/phase-2-enhanced-features/PLAN.md +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -34,7 +34,7 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), | Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ | | Phase 3: Localization | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 4: Widgets | fullstack | Done | ⬜ | ⬜ | ⬜ | -| Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Access Control | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 6: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | ## Final Review diff --git a/plans/phase-2-enhanced-features/phase-5-access-control.md b/plans/phase-2-enhanced-features/phase-5-access-control.md index ce56843..3e3d602 100644 --- a/plans/phase-2-enhanced-features/phase-5-access-control.md +++ b/plans/phase-2-enhanced-features/phase-5-access-control.md @@ -1,6 +1,6 @@ # Phase 4: Per-Board Access Control UI -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,14 +9,14 @@ Add a user-friendly access control interface for boards, allowing admins to mana ## 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 +- [x] Task 1: Create `src/lib/components/board/BoardAccessControl.svelte` — inline permission editor for boards +- [x] Task 2: Add access control tab/section to board editor page +- [x] Task 3: Create `src/routes/api/boards/[id]/permissions/+server.ts` — GET/POST/DELETE permissions for a board +- [x] Task 4: Update `src/lib/components/admin/PermissionEditor.svelte` — enhance with user/group search/autocomplete +- [x] Task 5: Update `src/lib/components/board/BoardCard.svelte` — show access level indicator (icon/badge) +- [x] Task 6: Update `src/routes/boards/+page.svelte` — show access indicators on board list +- [x] Task 7: Add guest access toggle with preview description to board editor +- [x] 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 @@ -26,7 +26,12 @@ Add a user-friendly access control interface for boards, allowing admins to mana - `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 +- `src/routes/boards/+page.svelte` — MODIFY (server only — +page.server.ts) +- `src/routes/boards/[boardId]/+page.svelte` — MODIFY +- `src/routes/boards/[boardId]/+page.server.ts` — MODIFY +- `src/lib/components/board/BoardHeader.svelte` — MODIFY +- `src/lib/i18n/en.json` — MODIFY +- `src/lib/i18n/ru.json` — MODIFY ## Acceptance Criteria - Board editor has a permissions section for managing access @@ -38,14 +43,24 @@ Add a user-friendly access control interface for boards, allowing admins to mana ## 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 +- ⚠️ Big Bang: 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 - +- Created `BoardAccessControl.svelte` — self-contained board permission manager with search/autocomplete, fetches from `/api/boards/[id]/permissions` +- Created `BoardShareDialog.svelte` — modal dialog for quick sharing with copy link, guest toggle, and permission management +- Created `/api/boards/[id]/permissions` API endpoint with GET/POST/DELETE for board-scoped permissions +- Enhanced `PermissionEditor.svelte` with search/autocomplete inputs replacing plain dropdowns +- Updated `BoardCard.svelte` with globe (guest), lock (private), and users (shared) icons +- Updated board editor with dedicated Guest Access and Permissions sections +- Updated `BoardHeader.svelte` with Share button that opens the share dialog +- Updated board view page (`[boardId]/+page.svelte`) and its server load to support share dialog with user/group data +- Updated boards list server to compute `hasSharedPermissions` flag per board +- Added ~20 new i18n keys in both `en.json` and `ru.json` for all new UI strings +- Big Bang strategy: no build/test verification — Phase 6 integration may be needed diff --git a/src/lib/components/admin/PermissionEditor.svelte b/src/lib/components/admin/PermissionEditor.svelte index 6983471..fe4e28c 100644 --- a/src/lib/components/admin/PermissionEditor.svelte +++ b/src/lib/components/admin/PermissionEditor.svelte @@ -51,6 +51,10 @@ let selectedTargetType = $state(TargetType.USER); let selectedTargetId = $state(''); let selectedLevel = $state(PermissionLevel.VIEW); + let entitySearchQuery = $state(''); + let targetSearchQuery = $state(''); + let showEntityDropdown = $state(false); + let showTargetDropdown = $state(false); let entityOptions = $derived( selectedEntityType === EntityType.APP ? apps : boards @@ -60,6 +64,22 @@ selectedTargetType === TargetType.USER ? users : groups ); + let filteredEntityOptions = $derived( + entitySearchQuery.length > 0 + ? entityOptions.filter((opt) => + opt.name.toLowerCase().includes(entitySearchQuery.toLowerCase()) + ) + : entityOptions + ); + + let filteredTargetOptions = $derived( + targetSearchQuery.length > 0 + ? targetOptions.filter((opt) => + opt.name.toLowerCase().includes(targetSearchQuery.toLowerCase()) + ) + : targetOptions + ); + function handleGrant() { if (!selectedEntityId || !selectedTargetId) return; onGrant({ @@ -71,6 +91,8 @@ }); selectedEntityId = ''; selectedTargetId = ''; + entitySearchQuery = ''; + targetSearchQuery = ''; } function handleRevoke(perm: PermissionRecord) { @@ -82,6 +104,18 @@ }); } + function selectEntity(option: SelectOption) { + selectedEntityId = option.id; + entitySearchQuery = option.name; + showEntityDropdown = false; + } + + function selectTarget(option: SelectOption) { + selectedTargetId = option.id; + targetSearchQuery = option.name; + showTargetDropdown = false; + } + function getEntityName(entityType: string, entityId: string): string { const list = entityType === EntityType.APP ? apps : boards; return list.find((e) => e.id === entityId)?.name ?? entityId; @@ -103,7 +137,7 @@
- - + +
+ { showEntityDropdown = true; }} + onblur={() => { setTimeout(() => { showEntityDropdown = false; }, 200); }} + placeholder={$t('admin.perm_search_placeholder')} + class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground" + /> + {#if showEntityDropdown && filteredEntityOptions.length > 0} +
+ {#each filteredEntityOptions as option (option.id)} + + {/each} +
+ {/if} +
- - + +
+ { showTargetDropdown = true; }} + onblur={() => { setTimeout(() => { showTargetDropdown = false; }, 200); }} + placeholder={$t('admin.perm_search_placeholder')} + class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground" + /> + {#if showTargetDropdown && filteredTargetOptions.length > 0} +
+ {#each filteredTargetOptions as option (option.id)} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/board/BoardAccessControl.svelte b/src/lib/components/board/BoardAccessControl.svelte new file mode 100644 index 0000000..29fe182 --- /dev/null +++ b/src/lib/components/board/BoardAccessControl.svelte @@ -0,0 +1,270 @@ + + +
+ +
+

{$t('board.access_grant')}

+
+
+ + +
+
+ +
+ + {#if searchQuery.length > 0 && filteredTargetOptions.length > 0} +
+ {#each filteredTargetOptions as option (option.id)} + + {/each} +
+ {/if} +
+ {#if !searchQuery && targetOptions.length > 0} + + {/if} +
+
+ + +
+
+ +
+
+
+ + {#if errorMessage} +

{errorMessage}

+ {/if} + + + {#if loading} +

{$t('board.access_loading')}

+ {:else if permissions.length > 0} +
+ + + + + + + + + + {#each permissions as perm (perm.id)} + + + + + + {/each} + +
{$t('admin.perm_target_column')}{$t('admin.perm_level_column')}{$t('admin.perm_action_column')}
+ {getTargetTypeLabel(perm.targetType)}: + {getTargetName(perm.targetType, perm.targetId)} + + + {getLevelLabel(perm.level)} + + + +
+
+ {:else} +

{$t('board.access_none')}

+ {/if} +
diff --git a/src/lib/components/board/BoardCard.svelte b/src/lib/components/board/BoardCard.svelte index 135f85c..e2c97d7 100644 --- a/src/lib/components/board/BoardCard.svelte +++ b/src/lib/components/board/BoardCard.svelte @@ -10,6 +10,7 @@ isDefault: boolean; isGuestAccessible: boolean; _count?: { sections: number }; + hasSharedPermissions?: boolean; } interface Props { @@ -44,9 +45,31 @@ {/if} {#if board.isGuestAccessible} - + + + + + + {$t('board.guest')} + {:else} + + + + + + + {/if} + {#if board.hasSharedPermissions} + + + + + + + + {/if}
{#if board.description} diff --git a/src/lib/components/board/BoardHeader.svelte b/src/lib/components/board/BoardHeader.svelte index 98dba8c..8c1dd89 100644 --- a/src/lib/components/board/BoardHeader.svelte +++ b/src/lib/components/board/BoardHeader.svelte @@ -8,9 +8,10 @@ icon: string | null; boardId: string; canEdit: boolean; + onShare?: () => void; } - let { name, description, icon, boardId, canEdit }: Props = $props(); + let { name, description, icon, boardId, canEdit, onShare }: Props = $props();
@@ -33,6 +34,22 @@ > {$t('board.all_boards')} + {#if canEdit && onShare} + + {/if} {#if canEdit} + import { t } from 'svelte-i18n'; + import { TargetType, PermissionLevel } from '$lib/utils/constants.js'; + + interface PermissionRecord { + id: string; + entityType: string; + entityId: string; + targetType: string; + targetId: string; + level: string; + createdAt: string; + } + + interface SelectOption { + id: string; + name: string; + } + + interface Props { + boardId: string; + boardName: string; + isGuestAccessible: boolean; + users: SelectOption[]; + groups: SelectOption[]; + onClose: () => void; + onGuestToggle: (value: boolean) => void; + } + + let { + boardId, + boardName, + isGuestAccessible, + users, + groups, + onClose, + onGuestToggle + }: Props = $props(); + + let permissions = $state([]); + let loading = $state(true); + let errorMessage = $state(''); + let copySuccess = $state(false); + + let selectedTargetType = $state(TargetType.USER); + let selectedTargetId = $state(''); + let selectedLevel = $state(PermissionLevel.VIEW); + let searchQuery = $state(''); + + let targetOptions = $derived( + selectedTargetType === TargetType.USER ? users : groups + ); + + let filteredTargetOptions = $derived( + searchQuery.length > 0 + ? targetOptions.filter((opt) => + opt.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : targetOptions + ); + + async function loadPermissions() { + loading = true; + errorMessage = ''; + try { + const res = await fetch(`/api/boards/${boardId}/permissions`); + const json = await res.json(); + if (json.success) { + permissions = json.data; + } else { + errorMessage = json.error ?? 'Failed to load permissions'; + } + } catch { + errorMessage = 'Network error'; + } finally { + loading = false; + } + } + + async function handleGrant() { + if (!selectedTargetId) return; + errorMessage = ''; + try { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetType: selectedTargetType, + targetId: selectedTargetId, + level: selectedLevel + }) + }); + const json = await res.json(); + if (json.success) { + selectedTargetId = ''; + searchQuery = ''; + await loadPermissions(); + } else { + errorMessage = json.error ?? 'Failed to grant permission'; + } + } catch { + errorMessage = 'Network error'; + } + } + + async function handleRevoke(perm: PermissionRecord) { + errorMessage = ''; + try { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetType: perm.targetType, + targetId: perm.targetId + }) + }); + const json = await res.json(); + if (json.success) { + await loadPermissions(); + } else { + errorMessage = json.error ?? 'Failed to revoke permission'; + } + } catch { + errorMessage = 'Network error'; + } + } + + function getTargetName(targetType: string, targetId: string): string { + const list = targetType === TargetType.USER ? users : groups; + return list.find((item) => item.id === targetId)?.name ?? targetId; + } + + function getLevelLabel(level: string): string { + switch (level) { + case PermissionLevel.VIEW: + return $t('admin.perm_view'); + case PermissionLevel.EDIT: + return $t('admin.perm_edit'); + case PermissionLevel.ADMIN: + return $t('admin.perm_admin'); + default: + return level; + } + } + + async function handleCopyLink() { + try { + const url = `${window.location.origin}/boards/${boardId}`; + await navigator.clipboard.writeText(url); + copySuccess = true; + setTimeout(() => { + copySuccess = false; + }, 2000); + } catch { + // Fallback: ignore if clipboard API not available + } + } + + function handleBackdropClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + onClose(); + } + } + + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + + // Load permissions on mount + $effect(() => { + loadPermissions(); + }); + + + + + + +
+ +
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 5565929..02e0c2d 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -52,6 +52,25 @@ "board.creating": "Creating...", "board.default_board": "Default board", "board.guest_accessible": "Guest accessible", + "board.guest_access_title": "Guest Access", + "board.guest_access_description": "When enabled, this board is visible to unauthenticated visitors without requiring sign-in.", + "board.guest_access_enabled": "This board is publicly accessible", + "board.guest_access_disabled": "This board is private", + "board.permissions_title": "Permissions", + "board.permissions_description": "Manage who can view, edit, or administer this board.", + "board.access_grant": "Grant Access", + "board.access_search_placeholder": "Search...", + "board.access_loading": "Loading permissions...", + "board.access_none": "No permissions configured for this board.", + "board.access_private": "Private", + "board.access_shared": "Shared", + "board.share": "Share", + "board.share_title": "Share \"{name}\"", + "board.share_copy_link": "Copy Link", + "board.share_copied": "Copied!", + "board.share_guest_description": "Anyone with the link can view this board without signing in.", + "board.share_add_access": "Add People or Groups", + "board.share_current_access": "Current Access", "section.title_label": "Title", "section.icon_label": "Icon", @@ -192,6 +211,7 @@ "admin.perm_level_column": "Level", "admin.perm_action_column": "Action", "admin.perm_none": "No permissions configured.", + "admin.perm_search_placeholder": "Type to search...", "search.placeholder": "Search apps and boards...", "search.trigger": "Search...", diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index b68cbff..db6c236 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -52,6 +52,25 @@ "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", + "board.guest_access_title": "\u0413\u043e\u0441\u0442\u0435\u0432\u043e\u0439 \u0434\u043e\u0441\u0442\u0443\u043f", + "board.guest_access_description": "\u041f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u044d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u0432\u0438\u0434\u043d\u0430 \u043d\u0435\u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u043f\u043e\u0441\u0435\u0442\u0438\u0442\u0435\u043b\u044f\u043c \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.", + "board.guest_access_enabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430", + "board.guest_access_disabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u0430", + "board.permissions_title": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "board.permissions_description": "\u0423\u043f\u0440\u0430\u0432\u043b\u044f\u0439\u0442\u0435, \u043a\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c, \u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438\u043b\u0438 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u0443 \u0434\u043e\u0441\u043a\u0443.", + "board.access_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f", + "board.access_search_placeholder": "\u041f\u043e\u0438\u0441\u043a...", + "board.access_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u0440\u0430\u0432...", + "board.access_none": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", + "board.access_private": "\u041f\u0440\u0438\u0432\u0430\u0442\u043d\u0430\u044f", + "board.access_shared": "\u041e\u0431\u0449\u0430\u044f", + "board.share": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f", + "board.share_title": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f \u00ab{name}\u00bb", + "board.share_copy_link": "\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0443", + "board.share_copied": "\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!", + "board.share_guest_description": "\u041b\u044e\u0431\u043e\u0439 \u0441 \u044d\u0442\u043e\u0439 \u0441\u0441\u044b\u043b\u043a\u043e\u0439 \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443 \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430.", + "board.share_add_access": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043b\u044e\u0434\u0435\u0439 \u0438\u043b\u0438 \u0433\u0440\u0443\u043f\u043f\u044b", + "board.share_current_access": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u0434\u043e\u0441\u0442\u0443\u043f", "section.title_label": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a", "section.icon_label": "\u0418\u043a\u043e\u043d\u043a\u0430", @@ -192,6 +211,7 @@ "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.", + "admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...", "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...", diff --git a/src/routes/api/boards/[id]/permissions/+server.ts b/src/routes/api/boards/[id]/permissions/+server.ts new file mode 100644 index 0000000..f91a078 --- /dev/null +++ b/src/routes/api/boards/[id]/permissions/+server.ts @@ -0,0 +1,175 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { EntityType, PermissionLevel, TargetType, UserRole } from '$lib/utils/constants.js'; + +/** + * GET /api/boards/:id/permissions — List all permissions for a board. + * Requires edit+ permission on the board, or admin role. + */ +export const GET: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + // Only admins or users with edit+ permission can view board permissions + 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 }); + } + } + + try { + const permissions = await permissionService.getPermissionsForEntity(EntityType.BOARD, id); + return json(success(permissions)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch permissions'; + return json(error(message), { status: 500 }); + } +}; + +/** + * POST /api/boards/:id/permissions — Grant a permission on a board. + * Body: { targetType: 'user' | 'group', targetId: string, level: 'view' | 'edit' | 'admin' } + * Requires admin permission on the board, or admin role. + */ +export const POST: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + // Only admins or users with admin permission on the board can grant permissions + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.ADMIN + ); + 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 { targetType, targetId, level } = body as { + targetType?: string; + targetId?: string; + level?: string; + }; + + // Validate targetType + if (!targetType || ![TargetType.USER, TargetType.GROUP].includes(targetType as TargetType)) { + return json(error('Invalid targetType: must be "user" or "group"'), { status: 400 }); + } + + // Validate targetId + if (!targetId || typeof targetId !== 'string') { + return json(error('targetId is required'), { status: 400 }); + } + + // Validate level + if ( + !level || + ![PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN].includes( + level as PermissionLevel + ) + ) { + return json(error('Invalid level: must be "view", "edit", or "admin"'), { status: 400 }); + } + + try { + const permission = await permissionService.grantPermission({ + entityType: EntityType.BOARD, + entityId: id, + targetType: targetType as TargetType, + targetId, + level: level as PermissionLevel + }); + return json(success(permission), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to grant permission'; + return json(error(message), { status: 500 }); + } +}; + +/** + * DELETE /api/boards/:id/permissions — Revoke a permission on a board. + * Body: { targetType: 'user' | 'group', targetId: string } + * Requires admin permission on the board, or admin role. + */ +export const DELETE: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + // Only admins or users with admin permission on the board can revoke permissions + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.ADMIN + ); + 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 { targetType, targetId } = body as { + targetType?: string; + targetId?: string; + }; + + // Validate targetType + if (!targetType || ![TargetType.USER, TargetType.GROUP].includes(targetType as TargetType)) { + return json(error('Invalid targetType: must be "user" or "group"'), { status: 400 }); + } + + // Validate targetId + if (!targetId || typeof targetId !== 'string') { + return json(error('targetId is required'), { status: 400 }); + } + + try { + await permissionService.revokePermission( + EntityType.BOARD, + id, + targetType as TargetType, + targetId + ); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to revoke permission'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts index a588275..a4ecc09 100644 --- a/src/routes/boards/+page.server.ts +++ b/src/routes/boards/+page.server.ts @@ -19,7 +19,20 @@ export const load: PageServerLoad = async ({ locals }) => { if (user.role === UserRole.ADMIN) { const boards = await boardService.findAllBoards(); - return { boards, isGuest: false }; + // For admins, check which boards have shared permissions + const boardsWithShared = await Promise.all( + boards.map(async (board) => { + const permissions = await permissionService.getPermissionsForEntity( + EntityType.BOARD, + board.id + ); + return { + ...board, + hasSharedPermissions: permissions.length > 0 + }; + }) + ); + return { boards: boardsWithShared, isGuest: false }; } // Regular user: filter by permissions @@ -28,7 +41,7 @@ export const load: PageServerLoad = async ({ locals }) => { for (const board of allBoards) { if (board.isGuestAccessible) { - accessibleBoards.push(board); + accessibleBoards.push({ ...board, hasSharedPermissions: false }); continue; } @@ -40,7 +53,14 @@ export const load: PageServerLoad = async ({ locals }) => { ); if (result.hasPermission) { - accessibleBoards.push(board); + const permissions = await permissionService.getPermissionsForEntity( + EntityType.BOARD, + board.id + ); + accessibleBoards.push({ + ...board, + hasSharedPermissions: permissions.length > 0 + }); } } diff --git a/src/routes/boards/[boardId]/+page.server.ts b/src/routes/boards/[boardId]/+page.server.ts index d70ca40..a0e7eaa 100644 --- a/src/routes/boards/[boardId]/+page.server.ts +++ b/src/routes/boards/[boardId]/+page.server.ts @@ -3,6 +3,8 @@ 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 * as userService from '$lib/server/services/userService.js'; +import * as groupService from '$lib/server/services/groupService.js'; import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; @@ -54,7 +56,26 @@ export const load: PageServerLoad = async ({ params, locals }) => { } } - return { board, canEdit, allApps }; + // Load users and groups for the share dialog (only if user can edit) + let users: { id: string; name: string }[] = []; + let groups: { id: string; name: string }[] = []; + + if (canEdit) { + const [allUsers, allGroups] = await Promise.all([ + userService.findAll(), + groupService.findAll() + ]); + users = allUsers.map((u) => ({ + id: u.id, + name: u.displayName || u.email + })); + groups = allGroups.map((g) => ({ + id: g.id, + name: g.name + })); + } + + return { board, canEdit, allApps, users, groups }; } 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 0d4426a..43849a1 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -3,8 +3,23 @@ import type { PageData } from './$types.js'; import Board from '$lib/components/board/Board.svelte'; import BoardHeader from '$lib/components/board/BoardHeader.svelte'; + import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte'; let { data }: { data: PageData } = $props(); + + let showShareDialog = $state(false); + + async function handleGuestToggle(value: boolean) { + try { + await fetch(`/api/boards/${data.board.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isGuestAccessible: value }) + }); + } catch (err) { + console.error('Failed to update guest access:', err); + } + } @@ -19,8 +34,21 @@ icon={data.board.icon} boardId={data.board.id} canEdit={data.canEdit} + onShare={() => { showShareDialog = true; }} />
+ +{#if showShareDialog && data.canEdit} + { showShareDialog = false; }} + onGuestToggle={handleGuestToggle} + /> +{/if} diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts index fd7f872..0dd503e 100644 --- a/src/routes/boards/[boardId]/edit/+page.server.ts +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -3,6 +3,8 @@ import type { PageServerLoad, Actions } 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 * 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 { @@ -30,10 +32,44 @@ export const load: PageServerLoad = async (event) => { } try { - const board = await boardService.findBoardById(boardId); - const apps = await appService.findAll(); + const [board, apps, allUsers, allGroups] = await Promise.all([ + boardService.findBoardById(boardId), + appService.findAll(), + userService.findAll(), + groupService.findAll() + ]); - return { board, apps }; + // Determine if user has admin permission on this board (for showing permissions section) + let canManagePermissions = false; + if (user.role === UserRole.ADMIN) { + canManagePermissions = true; + } else { + const adminResult = await permissionService.checkPermission( + EntityType.BOARD, + boardId, + user.id, + PermissionLevel.ADMIN + ); + canManagePermissions = adminResult.hasPermission; + } + + const userOptions = allUsers.map((u) => ({ + id: u.id, + name: u.displayName || u.email + })); + + const groupOptions = allGroups.map((g) => ({ + id: g.id, + name: g.name + })); + + return { + board, + apps, + users: userOptions, + groups: groupOptions, + canManagePermissions + }; } catch (err) { const message = err instanceof Error ? err.message : 'Board not found'; if (message.includes('not found')) { diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index e645204..f995153 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -4,6 +4,7 @@ import { enhance } from '$app/forms'; import { invalidateAll } from '$app/navigation'; import DraggableBoard from '$lib/components/board/DraggableBoard.svelte'; + import BoardAccessControl from '$lib/components/board/BoardAccessControl.svelte'; import { WidgetType } from '$lib/utils/constants.js'; let { data }: { data: PageData } = $props(); @@ -161,15 +162,6 @@ /> {$t('board.default_board')} -
@@ -183,6 +175,68 @@ + +
+

{$t('board.guest_access_title')}

+
+
+ + + +
+ +
+
+
+
+ + + {#if data.canManagePermissions} +
+

{$t('board.permissions_title')}

+

{$t('board.permissions_description')}

+ +
+ {/if} +