Files
web-app-launcher/src/lib/components/admin/PermissionEditor.svelte
T
alexei.dolgolyov c5166ba3a9 feat(mvp): phase 6 - admin panel
Add admin layout with auth guard, user management (CRUD + group membership),
group management, system settings (auth mode, registration, theme, healthcheck),
permission editor component, and global search API endpoint.
2026-03-24 21:18:06 +03:00

221 lines
6.6 KiB
Svelte

<script lang="ts">
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 entityOptions = $derived(
selectedEntityType === EntityType.APP ? apps : boards
);
let targetOptions = $derived(
selectedTargetType === TargetType.USER ? users : groups
);
function handleGrant() {
if (!selectedEntityId || !selectedTargetId) return;
onGrant({
entityType: selectedEntityType,
entityId: selectedEntityId,
targetType: selectedTargetType,
targetId: selectedTargetId,
level: selectedLevel
});
selectedEntityId = '';
selectedTargetId = '';
}
function handleRevoke(perm: PermissionRecord) {
onRevoke({
entityType: perm.entityType,
entityId: perm.entityId,
targetType: perm.targetType,
targetId: perm.targetId
});
}
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">Grant Permission</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">Entity Type</label>
<select
id="perm-entity-type"
bind:value={selectedEntityType}
onchange={() => (selectedEntityId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={EntityType.BOARD}>Board</option>
<option value={EntityType.APP}>App</option>
</select>
</div>
<div>
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">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>Select...</option>
{#each entityOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">Target Type</label>
<select
id="perm-target-type"
bind:value={selectedTargetType}
onchange={() => (selectedTargetId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={TargetType.USER}>User</option>
<option value={TargetType.GROUP}>Group</option>
</select>
</div>
<div>
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">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>Select...</option>
{#each targetOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">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}>View</option>
<option value={PermissionLevel.EDIT}>Edit</option>
<option value={PermissionLevel.ADMIN}>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"
>
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">Entity</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Target</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Level</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Action</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"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-muted-foreground">No permissions configured.</p>
{/if}
</div>