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
@@ -51,6 +51,10 @@
let selectedTargetType = $state<string>(TargetType.USER);
let selectedTargetId = $state('');
let selectedLevel = $state<string>(PermissionLevel.VIEW);
let entitySearchQuery = $state('');
let targetSearchQuery = $state('');
let showEntityDropdown = $state(false);
let showTargetDropdown = $state(false);
let entityOptions = $derived(
selectedEntityType === EntityType.APP ? apps : boards
@@ -60,6 +64,22 @@
selectedTargetType === TargetType.USER ? users : groups
);
let filteredEntityOptions = $derived(
entitySearchQuery.length > 0
? entityOptions.filter((opt) =>
opt.name.toLowerCase().includes(entitySearchQuery.toLowerCase())
)
: entityOptions
);
let filteredTargetOptions = $derived(
targetSearchQuery.length > 0
? targetOptions.filter((opt) =>
opt.name.toLowerCase().includes(targetSearchQuery.toLowerCase())
)
: targetOptions
);
function handleGrant() {
if (!selectedEntityId || !selectedTargetId) return;
onGrant({
@@ -71,6 +91,8 @@
});
selectedEntityId = '';
selectedTargetId = '';
entitySearchQuery = '';
targetSearchQuery = '';
}
function handleRevoke(perm: PermissionRecord) {
@@ -82,6 +104,18 @@
});
}
function selectEntity(option: SelectOption) {
selectedEntityId = option.id;
entitySearchQuery = option.name;
showEntityDropdown = false;
}
function selectTarget(option: SelectOption) {
selectedTargetId = option.id;
targetSearchQuery = option.name;
showTargetDropdown = false;
}
function getEntityName(entityType: string, entityId: string): string {
const list = entityType === EntityType.APP ? apps : boards;
return list.find((e) => e.id === entityId)?.name ?? entityId;
@@ -103,7 +137,7 @@
<select
id="perm-entity-type"
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"
>
<option value={EntityType.BOARD}>{$t('admin.perm_board')}</option>
@@ -111,24 +145,38 @@
</select>
</div>
<div>
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
<select
id="perm-entity"
bind:value={selectedEntityId}
class="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 entityOptions as option (option.id)}
<option value={option.id}>{option.name}</option>
{/each}
</select>
<label for="perm-entity-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
<div class="relative">
<input
id="perm-entity-search"
type="text"
bind:value={entitySearchQuery}
onfocus={() => { showEntityDropdown = true; }}
onblur={() => { setTimeout(() => { showEntityDropdown = false; }, 200); }}
placeholder={$t('admin.perm_search_placeholder')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
/>
{#if showEntityDropdown && filteredEntityOptions.length > 0}
<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>
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
<select
id="perm-target-type"
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"
>
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
@@ -136,17 +184,31 @@
</select>
</div>
<div>
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
<select
id="perm-target"
bind:value={selectedTargetId}
class="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>
<label for="perm-target-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
<div class="relative">
<input
id="perm-target-search"
type="text"
bind:value={targetSearchQuery}
onfocus={() => { showTargetDropdown = true; }}
onblur={() => { setTimeout(() => { showTargetDropdown = false; }, 200); }}
placeholder={$t('admin.perm_search_placeholder')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
/>
{#if showTargetDropdown && filteredTargetOptions.length > 0}
<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>
<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>
+24 -1
View File
@@ -10,6 +10,7 @@
isDefault: boolean;
isGuestAccessible: boolean;
_count?: { sections: number };
hasSharedPermissions?: boolean;
}
interface Props {
@@ -44,9 +45,31 @@
</span>
{/if}
{#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')}
</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}
</div>
{#if board.description}
+18 -1
View File
@@ -8,9 +8,10 @@
icon: string | null;
boardId: string;
canEdit: boolean;
onShare?: () => void;
}
let { name, description, icon, boardId, canEdit }: Props = $props();
let { name, description, icon, boardId, canEdit, onShare }: Props = $props();
</script>
<div class="mb-6 flex items-start justify-between">
@@ -33,6 +34,22 @@
>
{$t('board.all_boards')}
</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}
<a
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>