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)
This commit is contained in:
@@ -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.*`
|
- 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
|
- 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 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
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU),
|
|||||||
| Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ |
|
| Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 3: Localization | fullstack | Done | ⬜ | ⬜ | ⬜ |
|
| Phase 3: Localization | fullstack | Done | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 4: Widgets | 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 | ⬜ | ⬜ | ⬜ |
|
| Phase 6: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
|
||||||
## Final Review
|
## Final Review
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4: Per-Board Access Control UI
|
# Phase 4: Per-Board Access Control UI
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** Done
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** fullstack
|
**Domain:** fullstack
|
||||||
|
|
||||||
@@ -9,14 +9,14 @@ Add a user-friendly access control interface for boards, allowing admins to mana
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Create `src/lib/components/board/BoardAccessControl.svelte` — inline permission editor for boards
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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
|
- [x] 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
|
- [x] 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 8: Create `src/lib/components/board/BoardShareDialog.svelte` — quick share dialog for boards
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `src/lib/components/board/BoardAccessControl.svelte` — NEW
|
- `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/routes/boards/[boardId]/edit/+page.server.ts` — MODIFY
|
||||||
- `src/lib/components/admin/PermissionEditor.svelte` — MODIFY
|
- `src/lib/components/admin/PermissionEditor.svelte` — MODIFY
|
||||||
- `src/lib/components/board/BoardCard.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
|
## Acceptance Criteria
|
||||||
- Board editor has a permissions section for managing access
|
- 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
|
## Notes
|
||||||
- The permission system already exists from MVP (permissionService)
|
- The permission system already exists from MVP (permissionService)
|
||||||
- This phase adds the UI layer on top of existing backend
|
- 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
|
## Review Checklist
|
||||||
- [ ] All tasks completed
|
- [x] All tasks completed
|
||||||
- [ ] Code follows project conventions
|
- [x] Code follows project conventions
|
||||||
- [ ] No unintended side effects
|
- [ ] No unintended side effects
|
||||||
- [ ] Build passes
|
- [ ] Build passes
|
||||||
- [ ] Tests pass (new + existing)
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this 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
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
let selectedTargetType = $state<string>(TargetType.USER);
|
let selectedTargetType = $state<string>(TargetType.USER);
|
||||||
let selectedTargetId = $state('');
|
let selectedTargetId = $state('');
|
||||||
let selectedLevel = $state<string>(PermissionLevel.VIEW);
|
let selectedLevel = $state<string>(PermissionLevel.VIEW);
|
||||||
|
let entitySearchQuery = $state('');
|
||||||
|
let targetSearchQuery = $state('');
|
||||||
|
let showEntityDropdown = $state(false);
|
||||||
|
let showTargetDropdown = $state(false);
|
||||||
|
|
||||||
let entityOptions = $derived(
|
let entityOptions = $derived(
|
||||||
selectedEntityType === EntityType.APP ? apps : boards
|
selectedEntityType === EntityType.APP ? apps : boards
|
||||||
@@ -60,6 +64,22 @@
|
|||||||
selectedTargetType === TargetType.USER ? users : groups
|
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() {
|
function handleGrant() {
|
||||||
if (!selectedEntityId || !selectedTargetId) return;
|
if (!selectedEntityId || !selectedTargetId) return;
|
||||||
onGrant({
|
onGrant({
|
||||||
@@ -71,6 +91,8 @@
|
|||||||
});
|
});
|
||||||
selectedEntityId = '';
|
selectedEntityId = '';
|
||||||
selectedTargetId = '';
|
selectedTargetId = '';
|
||||||
|
entitySearchQuery = '';
|
||||||
|
targetSearchQuery = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRevoke(perm: PermissionRecord) {
|
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 {
|
function getEntityName(entityType: string, entityId: string): string {
|
||||||
const list = entityType === EntityType.APP ? apps : boards;
|
const list = entityType === EntityType.APP ? apps : boards;
|
||||||
return list.find((e) => e.id === entityId)?.name ?? entityId;
|
return list.find((e) => e.id === entityId)?.name ?? entityId;
|
||||||
@@ -103,7 +137,7 @@
|
|||||||
<select
|
<select
|
||||||
id="perm-entity-type"
|
id="perm-entity-type"
|
||||||
bind:value={selectedEntityType}
|
bind:value={selectedEntityType}
|
||||||
onchange={() => (selectedEntityId = '')}
|
onchange={() => { selectedEntityId = ''; entitySearchQuery = ''; }}
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<option value={EntityType.BOARD}>{$t('admin.perm_board')}</option>
|
<option value={EntityType.BOARD}>{$t('admin.perm_board')}</option>
|
||||||
@@ -111,24 +145,38 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
|
<label for="perm-entity-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
|
||||||
<select
|
<div class="relative">
|
||||||
id="perm-entity"
|
<input
|
||||||
bind:value={selectedEntityId}
|
id="perm-entity-search"
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
type="text"
|
||||||
>
|
bind:value={entitySearchQuery}
|
||||||
<option value="" disabled>{$t('admin.perm_select')}</option>
|
onfocus={() => { showEntityDropdown = true; }}
|
||||||
{#each entityOptions as option (option.id)}
|
onblur={() => { setTimeout(() => { showEntityDropdown = false; }, 200); }}
|
||||||
<option value={option.id}>{option.name}</option>
|
placeholder={$t('admin.perm_search_placeholder')}
|
||||||
{/each}
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||||
</select>
|
/>
|
||||||
|
{#if showEntityDropdown && filteredEntityOptions.length > 0}
|
||||||
|
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||||
|
{#each filteredEntityOptions as option (option.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
||||||
|
onmousedown={() => selectEntity(option)}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
||||||
<select
|
<select
|
||||||
id="perm-target-type"
|
id="perm-target-type"
|
||||||
bind:value={selectedTargetType}
|
bind:value={selectedTargetType}
|
||||||
onchange={() => (selectedTargetId = '')}
|
onchange={() => { selectedTargetId = ''; targetSearchQuery = ''; }}
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
||||||
@@ -136,17 +184,31 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
<label for="perm-target-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
||||||
<select
|
<div class="relative">
|
||||||
id="perm-target"
|
<input
|
||||||
bind:value={selectedTargetId}
|
id="perm-target-search"
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
type="text"
|
||||||
>
|
bind:value={targetSearchQuery}
|
||||||
<option value="" disabled>{$t('admin.perm_select')}</option>
|
onfocus={() => { showTargetDropdown = true; }}
|
||||||
{#each targetOptions as option (option.id)}
|
onblur={() => { setTimeout(() => { showTargetDropdown = false; }, 200); }}
|
||||||
<option value={option.id}>{option.name}</option>
|
placeholder={$t('admin.perm_search_placeholder')}
|
||||||
{/each}
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||||
</select>
|
/>
|
||||||
|
{#if showTargetDropdown && filteredTargetOptions.length > 0}
|
||||||
|
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||||
|
{#each filteredTargetOptions as option (option.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
||||||
|
onmousedown={() => selectTarget(option)}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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;
|
||||||
|
users: SelectOption[];
|
||||||
|
groups: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { boardId, users, groups }: Props = $props();
|
||||||
|
|
||||||
|
let permissions = $state<PermissionRecord[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
let selectedTargetType = $state<string>(TargetType.USER);
|
||||||
|
let selectedTargetId = $state('');
|
||||||
|
let selectedLevel = $state<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetTypeLabel(targetType: string): string {
|
||||||
|
return targetType === TargetType.USER
|
||||||
|
? $t('admin.perm_user')
|
||||||
|
: $t('admin.perm_group');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load permissions on mount
|
||||||
|
$effect(() => {
|
||||||
|
loadPermissions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Grant form -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('board.access_grant')}</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label for="bac-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
||||||
|
<select
|
||||||
|
id="bac-target-type"
|
||||||
|
bind:value={selectedTargetType}
|
||||||
|
onchange={() => { selectedTargetId = ''; searchQuery = ''; }}
|
||||||
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
||||||
|
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bac-target" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="bac-target-search"
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder={$t('board.access_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 searchQuery.length > 0 && filteredTargetOptions.length > 0}
|
||||||
|
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||||
|
{#each filteredTargetOptions as option (option.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
||||||
|
onclick={() => { selectedTargetId = option.id; searchQuery = option.name; }}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !searchQuery && targetOptions.length > 0}
|
||||||
|
<select
|
||||||
|
id="bac-target"
|
||||||
|
bind:value={selectedTargetId}
|
||||||
|
class="mt-1 w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="" disabled>{$t('admin.perm_select')}</option>
|
||||||
|
{#each targetOptions as option (option.id)}
|
||||||
|
<option value={option.id}>{option.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bac-level" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
||||||
|
<select
|
||||||
|
id="bac-level"
|
||||||
|
bind:value={selectedLevel}
|
||||||
|
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
||||||
|
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
||||||
|
<option value={PermissionLevel.ADMIN}>{$t('admin.perm_admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleGrant}
|
||||||
|
disabled={!selectedTargetId}
|
||||||
|
class="w-full rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$t('admin.perm_grant')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Existing permissions list -->
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-sm text-muted-foreground">{$t('board.access_loading')}</p>
|
||||||
|
{:else if permissions.length > 0}
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-border bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_target_column')}</th>
|
||||||
|
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_level_column')}</th>
|
||||||
|
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_action_column')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each permissions as perm (perm.id)}
|
||||||
|
<tr class="border-b border-border last:border-b-0">
|
||||||
|
<td class="px-4 py-2 text-foreground">
|
||||||
|
<span class="mr-1 text-xs text-muted-foreground">{getTargetTypeLabel(perm.targetType)}:</span>
|
||||||
|
{getTargetName(perm.targetType, perm.targetId)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
|
||||||
|
{getLevelLabel(perm.level)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleRevoke(perm)}
|
||||||
|
class="text-xs text-destructive hover:underline"
|
||||||
|
>
|
||||||
|
{$t('admin.perm_revoke')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">{$t('board.access_none')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
isGuestAccessible: boolean;
|
isGuestAccessible: boolean;
|
||||||
_count?: { sections: number };
|
_count?: { sections: number };
|
||||||
|
hasSharedPermissions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,9 +45,31 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if board.isGuestAccessible}
|
{#if board.isGuestAccessible}
|
||||||
<span class="shrink-0 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground">
|
<span class="shrink-0 flex items-center gap-1 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground" title={$t('board.guest_accessible')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
{$t('board.guest')}
|
{$t('board.guest')}
|
||||||
</span>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="shrink-0 flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" title={$t('board.access_private')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if board.hasSharedPermissions}
|
||||||
|
<span class="shrink-0 flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-500" title={$t('board.access_shared')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if board.description}
|
{#if board.description}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
boardId: string;
|
boardId: string;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
onShare?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, description, icon, boardId, canEdit }: Props = $props();
|
let { name, description, icon, boardId, canEdit, onShare }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-start justify-between">
|
<div class="mb-6 flex items-start justify-between">
|
||||||
@@ -33,6 +34,22 @@
|
|||||||
>
|
>
|
||||||
{$t('board.all_boards')}
|
{$t('board.all_boards')}
|
||||||
</a>
|
</a>
|
||||||
|
{#if canEdit && onShare}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onShare}
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.share')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<a
|
<a
|
||||||
href="/boards/{boardId}/edit"
|
href="/boards/{boardId}/edit"
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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<PermissionRecord[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let copySuccess = $state(false);
|
||||||
|
|
||||||
|
let selectedTargetType = $state<string>(TargetType.USER);
|
||||||
|
let selectedTargetId = $state('');
|
||||||
|
let selectedLevel = $state<string>(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();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div class="mx-4 w-full max-w-lg rounded-xl border border-border bg-card p-6 shadow-xl" role="dialog" aria-modal="true">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-card-foreground">
|
||||||
|
{$t('board.share_title', { values: { name: boardName } })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label={$t('common.cancel')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy link -->
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleCopyLink}
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
{copySuccess ? $t('board.share_copied') : $t('board.share_copy_link')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guest access toggle -->
|
||||||
|
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<label class="flex items-center gap-3 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGuestAccessible}
|
||||||
|
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
|
||||||
|
class="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{$t('board.guest_accessible')}</span>
|
||||||
|
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick add permission -->
|
||||||
|
<div class="mb-4 rounded-lg border border-border p-3">
|
||||||
|
<h3 class="mb-2 text-sm font-medium text-card-foreground">{$t('board.share_add_access')}</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
bind:value={selectedTargetType}
|
||||||
|
onchange={() => { selectedTargetId = ''; searchQuery = ''; }}
|
||||||
|
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
||||||
|
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
||||||
|
</select>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder={$t('board.access_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 searchQuery.length > 0 && filteredTargetOptions.length > 0 && !selectedTargetId}
|
||||||
|
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||||
|
{#each filteredTargetOptions as option (option.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
||||||
|
onclick={() => { selectedTargetId = option.id; searchQuery = option.name; }}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
bind:value={selectedLevel}
|
||||||
|
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
||||||
|
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
||||||
|
<option value={PermissionLevel.ADMIN}>{$t('admin.perm_admin')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleGrant}
|
||||||
|
disabled={!selectedTargetId}
|
||||||
|
class="shrink-0 rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="mb-3 text-sm text-destructive">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Current access list -->
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-sm text-muted-foreground">{$t('board.access_loading')}</p>
|
||||||
|
{:else if permissions.length > 0}
|
||||||
|
<h3 class="mb-2 text-sm font-medium text-card-foreground">{$t('board.share_current_access')}</h3>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each permissions as perm (perm.id)}
|
||||||
|
<div class="flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
||||||
|
{#if perm.targetType === TargetType.USER}
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
{:else}
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<span class="text-foreground">{getTargetName(perm.targetType, perm.targetId)}</span>
|
||||||
|
<span class="rounded-full bg-accent px-2 py-0.5 text-xs text-accent-foreground">
|
||||||
|
{getLevelLabel(perm.level)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleRevoke(perm)}
|
||||||
|
class="text-xs text-destructive hover:underline"
|
||||||
|
>
|
||||||
|
{$t('admin.perm_revoke')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">{$t('board.access_none')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -52,6 +52,25 @@
|
|||||||
"board.creating": "Creating...",
|
"board.creating": "Creating...",
|
||||||
"board.default_board": "Default board",
|
"board.default_board": "Default board",
|
||||||
"board.guest_accessible": "Guest accessible",
|
"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.title_label": "Title",
|
||||||
"section.icon_label": "Icon",
|
"section.icon_label": "Icon",
|
||||||
@@ -192,6 +211,7 @@
|
|||||||
"admin.perm_level_column": "Level",
|
"admin.perm_level_column": "Level",
|
||||||
"admin.perm_action_column": "Action",
|
"admin.perm_action_column": "Action",
|
||||||
"admin.perm_none": "No permissions configured.",
|
"admin.perm_none": "No permissions configured.",
|
||||||
|
"admin.perm_search_placeholder": "Type to search...",
|
||||||
|
|
||||||
"search.placeholder": "Search apps and boards...",
|
"search.placeholder": "Search apps and boards...",
|
||||||
"search.trigger": "Search...",
|
"search.trigger": "Search...",
|
||||||
|
|||||||
@@ -52,6 +52,25 @@
|
|||||||
"board.creating": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435...",
|
"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.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_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.title_label": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a",
|
||||||
"section.icon_label": "\u0418\u043a\u043e\u043d\u043a\u0430",
|
"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_level_column": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c",
|
||||||
"admin.perm_action_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435",
|
"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_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.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.trigger": "\u041f\u043e\u0438\u0441\u043a...",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,7 +19,20 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
|
|
||||||
if (user.role === UserRole.ADMIN) {
|
if (user.role === UserRole.ADMIN) {
|
||||||
const boards = await boardService.findAllBoards();
|
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
|
// Regular user: filter by permissions
|
||||||
@@ -28,7 +41,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
|
|
||||||
for (const board of allBoards) {
|
for (const board of allBoards) {
|
||||||
if (board.isGuestAccessible) {
|
if (board.isGuestAccessible) {
|
||||||
accessibleBoards.push(board);
|
accessibleBoards.push({ ...board, hasSharedPermissions: false });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +53,14 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.hasPermission) {
|
if (result.hasPermission) {
|
||||||
accessibleBoards.push(board);
|
const permissions = await permissionService.getPermissionsForEntity(
|
||||||
|
EntityType.BOARD,
|
||||||
|
board.id
|
||||||
|
);
|
||||||
|
accessibleBoards.push({
|
||||||
|
...board,
|
||||||
|
hasSharedPermissions: permissions.length > 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { PageServerLoad } from './$types.js';
|
|||||||
import * as boardService from '$lib/server/services/boardService.js';
|
import * as boardService from '$lib/server/services/boardService.js';
|
||||||
import * as appService from '$lib/server/services/appService.js';
|
import * as appService from '$lib/server/services/appService.js';
|
||||||
import * as permissionService from '$lib/server/services/permissionService.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 { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||||
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Board not found';
|
const message = err instanceof Error ? err.message : 'Board not found';
|
||||||
if (message.includes('not found')) {
|
if (message.includes('not found')) {
|
||||||
|
|||||||
@@ -3,8 +3,23 @@
|
|||||||
import type { PageData } from './$types.js';
|
import type { PageData } from './$types.js';
|
||||||
import Board from '$lib/components/board/Board.svelte';
|
import Board from '$lib/components/board/Board.svelte';
|
||||||
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
|
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
|
||||||
|
import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -19,8 +34,21 @@
|
|||||||
icon={data.board.icon}
|
icon={data.board.icon}
|
||||||
boardId={data.board.id}
|
boardId={data.board.id}
|
||||||
canEdit={data.canEdit}
|
canEdit={data.canEdit}
|
||||||
|
onShare={() => { showShareDialog = true; }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Board sections={data.board.sections} allApps={data.allApps} />
|
<Board sections={data.board.sections} allApps={data.allApps} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showShareDialog && data.canEdit}
|
||||||
|
<BoardShareDialog
|
||||||
|
boardId={data.board.id}
|
||||||
|
boardName={data.board.name}
|
||||||
|
isGuestAccessible={data.board.isGuestAccessible}
|
||||||
|
users={data.users ?? []}
|
||||||
|
groups={data.groups ?? []}
|
||||||
|
onClose={() => { showShareDialog = false; }}
|
||||||
|
onGuestToggle={handleGuestToggle}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { PageServerLoad, Actions } from './$types.js';
|
|||||||
import * as boardService from '$lib/server/services/boardService.js';
|
import * as boardService from '$lib/server/services/boardService.js';
|
||||||
import * as appService from '$lib/server/services/appService.js';
|
import * as appService from '$lib/server/services/appService.js';
|
||||||
import * as permissionService from '$lib/server/services/permissionService.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 { requireAuth } from '$lib/server/middleware/authenticate.js';
|
||||||
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,10 +32,44 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const board = await boardService.findBoardById(boardId);
|
const [board, apps, allUsers, allGroups] = await Promise.all([
|
||||||
const apps = await appService.findAll();
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Board not found';
|
const message = err instanceof Error ? err.message : 'Board not found';
|
||||||
if (message.includes('not found')) {
|
if (message.includes('not found')) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
|
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
|
||||||
|
import BoardAccessControl from '$lib/components/board/BoardAccessControl.svelte';
|
||||||
import { WidgetType } from '$lib/utils/constants.js';
|
import { WidgetType } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -161,15 +162,6 @@
|
|||||||
/>
|
/>
|
||||||
{$t('board.default_board')}
|
{$t('board.default_board')}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="isGuestAccessible"
|
|
||||||
checked={data.board.isGuestAccessible}
|
|
||||||
class="h-4 w-4 rounded border-input accent-primary"
|
|
||||||
/>
|
|
||||||
{$t('board.guest_accessible')}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -183,6 +175,68 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Guest Access -->
|
||||||
|
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.guest_access_title')}</h2>
|
||||||
|
<div class="rounded-lg border border-border bg-muted/30 p-4">
|
||||||
|
<form method="POST" action="?/updateBoard" use:enhance>
|
||||||
|
<input type="hidden" name="name" value={data.board.name} />
|
||||||
|
<input type="hidden" name="isDefault" value={data.board.isDefault ? 'on' : ''} />
|
||||||
|
<label class="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="isGuestAccessible"
|
||||||
|
checked={data.board.isGuestAccessible}
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{$t('board.guest_accessible')}</span>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{$t('board.guest_access_description')}</p>
|
||||||
|
{#if data.board.isGuestAccessible}
|
||||||
|
<p class="mt-1 flex items-center gap-1 text-xs text-green-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.guest_access_enabled')}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.guest_access_disabled')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{$t('board.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
{#if data.canManagePermissions}
|
||||||
|
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.permissions_title')}</h2>
|
||||||
|
<p class="mb-4 text-sm text-muted-foreground">{$t('board.permissions_description')}</p>
|
||||||
|
<BoardAccessControl
|
||||||
|
boardId={data.board.id}
|
||||||
|
users={data.users}
|
||||||
|
groups={data.groups}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Sections with Drag-and-Drop -->
|
<!-- Sections with Drag-and-Drop -->
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user