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.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface GroupWithCount {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: Date;
|
||||
_count: { users: number };
|
||||
}
|
||||
|
||||
let { groups }: { groups: GroupWithCount[] } = $props();
|
||||
|
||||
let editingGroupId = $state<string | null>(null);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editIsDefault = $state(false);
|
||||
|
||||
function startEdit(group: GroupWithCount) {
|
||||
editingGroupId = group.id;
|
||||
editName = group.name;
|
||||
editDescription = group.description ?? '';
|
||||
editIsDefault = group.isDefault;
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-3 font-medium text-muted-foreground">Name</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Description</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Members</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Default</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each groups as group (group.id)}
|
||||
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
|
||||
{#if editingGroupId === group.id}
|
||||
<td colspan="5" class="px-4 py-3">
|
||||
<form method="POST" action="?/update" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
editingGroupId = null;
|
||||
await update();
|
||||
};
|
||||
}} class="flex items-center gap-3">
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
type="text"
|
||||
bind:value={editDescription}
|
||||
placeholder="Description"
|
||||
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
<label class="flex items-center gap-1 text-xs text-foreground">
|
||||
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
|
||||
Default
|
||||
</label>
|
||||
<button type="submit" class="text-xs text-primary hover:underline">Save</button>
|
||||
<button type="button" onclick={() => (editingGroupId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<td class="px-4 py-3 font-medium text-foreground">{group.name}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{group.description ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{group._count.users}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if group.isDefault}
|
||||
<span class="inline-flex rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">Yes</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEdit(group)}
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if confirmDeleteId === group.id}
|
||||
<form method="POST" action="?/delete" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
confirmDeleteId = null;
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<span class="text-xs text-destructive">Confirm?</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDeleteId = group.id)}
|
||||
class="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No groups found.</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,220 @@
|
||||
<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>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { superForm, type SuperValidated } from 'sveltekit-superforms/client';
|
||||
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
||||
|
||||
const { form, errors, enhance, delayed } = superForm(formData);
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
||||
<!-- Authentication -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Authentication</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">Auth Mode</label>
|
||||
<select
|
||||
id="authMode"
|
||||
name="authMode"
|
||||
bind:value={$form.authMode}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="local">Local</option>
|
||||
<option value="oauth">OAuth</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-6">
|
||||
<input
|
||||
id="registrationEnabled"
|
||||
name="registrationEnabled"
|
||||
type="checkbox"
|
||||
bind:checked={$form.registrationEnabled}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
|
||||
Allow user registration
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OAuth (stored but non-functional in MVP) -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">OAuth settings are stored but not active in this MVP version.</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
|
||||
<input
|
||||
id="oauthClientId"
|
||||
name="oauthClientId"
|
||||
type="text"
|
||||
bind:value={$form.oauthClientId}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="OAuth client ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="oauthClientSecret" class="mb-1 block text-sm font-medium text-foreground">Client Secret</label>
|
||||
<input
|
||||
id="oauthClientSecret"
|
||||
name="oauthClientSecret"
|
||||
type="password"
|
||||
bind:value={$form.oauthClientSecret}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="OAuth client secret"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="oauthDiscoveryUrl" class="mb-1 block text-sm font-medium text-foreground">Discovery URL</label>
|
||||
<input
|
||||
id="oauthDiscoveryUrl"
|
||||
name="oauthDiscoveryUrl"
|
||||
type="url"
|
||||
bind:value={$form.oauthDiscoveryUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="https://example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Theme Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Theme Defaults</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">Default Theme</label>
|
||||
<select
|
||||
id="defaultTheme"
|
||||
name="defaultTheme"
|
||||
bind:value={$form.defaultTheme}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">Default Primary Color</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="defaultPrimaryColor"
|
||||
name="defaultPrimaryColor"
|
||||
type="text"
|
||||
bind:value={$form.defaultPrimaryColor}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="#6366f1"
|
||||
pattern="^#[0-9a-fA-F]{6}$"
|
||||
/>
|
||||
{#if $form.defaultPrimaryColor}
|
||||
<div
|
||||
class="h-8 w-8 shrink-0 rounded border border-border"
|
||||
style:background-color={$form.defaultPrimaryColor}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $errors.defaultPrimaryColor}<span class="text-xs text-destructive">{$errors.defaultPrimaryColor}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Healthcheck Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Healthcheck Defaults</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">JSON configuration for default healthcheck behavior (interval, timeout, method).</p>
|
||||
<div>
|
||||
<label for="healthcheckDefaults" class="mb-1 block text-sm font-medium text-foreground">Defaults (JSON)</label>
|
||||
<textarea
|
||||
id="healthcheckDefaults"
|
||||
name="healthcheckDefaults"
|
||||
bind:value={$form.healthcheckDefaults}
|
||||
rows="4"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
|
||||
placeholder='{"interval": 300, "timeout": 5000, "method": "GET"}'
|
||||
></textarea>
|
||||
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $errors._errors}
|
||||
<p class="text-sm text-destructive">{$errors._errors}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={$delayed}
|
||||
>
|
||||
{$delayed ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface UserWithGroups {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
authProvider: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isDefault: boolean;
|
||||
_count: { users: number };
|
||||
}
|
||||
|
||||
let {
|
||||
users,
|
||||
groups
|
||||
}: {
|
||||
users: UserWithGroups[];
|
||||
groups: Group[];
|
||||
} = $props();
|
||||
|
||||
let editingUserId = $state<string | null>(null);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
let addGroupUserId = $state<string | null>(null);
|
||||
let selectedGroupId = $state('');
|
||||
</script>
|
||||
|
||||
<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-3 font-medium text-muted-foreground">User</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Email</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Role</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Provider</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Groups</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
|
||||
<td class="px-4 py-3 text-foreground">{user.displayName}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{user.email}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if editingUserId === user.id}
|
||||
<form method="POST" action="?/update" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
editingUserId = null;
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<select
|
||||
name="role"
|
||||
class="rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
<option value="user" selected={user.role === 'user'}>User</option>
|
||||
<option value="admin" selected={user.role === 'admin'}>Admin</option>
|
||||
</select>
|
||||
<button type="submit" class="ml-1 text-xs text-primary hover:underline">Save</button>
|
||||
<button type="button" onclick={() => (editingUserId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {user.role === 'admin' ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}">
|
||||
{user.role}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted-foreground">{user.authProvider}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each user.groups as group (group.id)}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs text-accent-foreground">
|
||||
{group.name}
|
||||
<form method="POST" action="?/removeFromGroup" use:enhance class="inline">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<button type="submit" class="text-muted-foreground hover:text-destructive" title="Remove from group">×</button>
|
||||
</form>
|
||||
</span>
|
||||
{/each}
|
||||
{#if addGroupUserId === user.id}
|
||||
<form method="POST" action="?/addToGroup" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
addGroupUserId = null;
|
||||
selectedGroupId = '';
|
||||
await update();
|
||||
};
|
||||
}} class="inline-flex items-center gap-1">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<select
|
||||
name="groupId"
|
||||
bind:value={selectedGroupId}
|
||||
class="rounded border border-input bg-background px-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<option value="" disabled>Select group</option>
|
||||
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="text-xs text-primary hover:underline" disabled={!selectedGroupId}>Add</button>
|
||||
<button type="button" onclick={() => (addGroupUserId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addGroupUserId = user.id)}
|
||||
class="rounded-full border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editingUserId = editingUserId === user.id ? null : user.id)}
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<form method="POST" action="?/delete" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
confirmDeleteId = null;
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<span class="text-xs text-destructive">Confirm?</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDeleteId = user.id)}
|
||||
class="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if users.length === 0}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No users found.</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user