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>