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:
@@ -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) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user