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:
2026-03-24 23:29:19 +03:00
parent 477c0e4d52
commit 5bb4fbcedf
16 changed files with 1166 additions and 57 deletions
@@ -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 });
}
};
+23 -3
View File
@@ -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
});
}
}
+22 -1
View File
@@ -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')) {
+28
View File
@@ -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);
}
}
</script>
<svelte:head>
@@ -19,8 +34,21 @@
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
onShare={() => { showShareDialog = true; }}
/>
<Board sections={data.board.sections} allApps={data.allApps} />
</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 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')) {
+63 -9
View File
@@ -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')}
</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 class="mt-4">
@@ -183,6 +175,68 @@
</form>
</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 -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">