5bb4fbcedf
- 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)
284 lines
9.2 KiB
Svelte
284 lines
9.2 KiB
Svelte
<script lang="ts">
|
|
import { t } from 'svelte-i18n';
|
|
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
|
|
|
interface PermissionRecord {
|
|
id: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
targetType: string;
|
|
targetId: string;
|
|
level: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
interface SelectOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
let {
|
|
permissions = [],
|
|
apps = [],
|
|
boards = [],
|
|
users = [],
|
|
groups = [],
|
|
onGrant,
|
|
onRevoke
|
|
}: {
|
|
permissions: PermissionRecord[];
|
|
apps: SelectOption[];
|
|
boards: SelectOption[];
|
|
users: SelectOption[];
|
|
groups: SelectOption[];
|
|
onGrant: (data: {
|
|
entityType: string;
|
|
entityId: string;
|
|
targetType: string;
|
|
targetId: string;
|
|
level: string;
|
|
}) => void;
|
|
onRevoke: (data: {
|
|
entityType: string;
|
|
entityId: string;
|
|
targetType: string;
|
|
targetId: string;
|
|
}) => void;
|
|
} = $props();
|
|
|
|
let selectedEntityType = $state<string>(EntityType.BOARD);
|
|
let selectedEntityId = $state('');
|
|
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
|
|
);
|
|
|
|
let targetOptions = $derived(
|
|
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({
|
|
entityType: selectedEntityType,
|
|
entityId: selectedEntityId,
|
|
targetType: selectedTargetType,
|
|
targetId: selectedTargetId,
|
|
level: selectedLevel
|
|
});
|
|
selectedEntityId = '';
|
|
selectedTargetId = '';
|
|
entitySearchQuery = '';
|
|
targetSearchQuery = '';
|
|
}
|
|
|
|
function handleRevoke(perm: PermissionRecord) {
|
|
onRevoke({
|
|
entityType: perm.entityType,
|
|
entityId: perm.entityId,
|
|
targetType: perm.targetType,
|
|
targetId: perm.targetId
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function getTargetName(targetType: string, targetId: string): string {
|
|
const list = targetType === TargetType.USER ? users : groups;
|
|
return list.find((t) => t.id === targetId)?.name ?? targetId;
|
|
}
|
|
</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('admin.perm_title')}</h3>
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
|
<div>
|
|
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity_type')}</label>
|
|
<select
|
|
id="perm-entity-type"
|
|
bind:value={selectedEntityType}
|
|
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>
|
|
<option value={EntityType.APP}>{$t('admin.perm_app')}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<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 = ''; 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>
|
|
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<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>
|
|
<div class="flex gap-1">
|
|
<select
|
|
id="perm-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>
|
|
<button
|
|
type="button"
|
|
onclick={handleGrant}
|
|
disabled={!selectedEntityId || !selectedTargetId}
|
|
class="shrink-0 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{$t('admin.perm_grant')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Existing permissions list -->
|
|
{#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_entity_column')}</th>
|
|
<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="text-xs text-muted-foreground">{perm.entityType}:</span>
|
|
{getEntityName(perm.entityType, perm.entityId)}
|
|
</td>
|
|
<td class="px-4 py-2 text-foreground">
|
|
<span class="text-xs text-muted-foreground">{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">
|
|
{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('admin.perm_none')}</p>
|
|
{/if}
|
|
</div>
|