mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-06 05:39:05 +00:00
Initial commit
This commit is contained in:
598
routes/settings/auth/users/UserModal.svelte
Normal file
598
routes/settings/auth/users/UserModal.svelte
Normal file
@@ -0,0 +1,598 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { User, UserPlus, Pencil, KeyRound, Crown, ShieldCheck, RefreshCw, Check, Globe, TriangleAlert, Shield, Eye, Wrench, Tag, Smartphone } from 'lucide-svelte';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import PasswordStrengthIndicator from '$lib/components/PasswordStrengthIndicator.svelte';
|
||||
|
||||
export interface LocalUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
mfaEnabled: boolean;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
isSso: boolean;
|
||||
lastLogin?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
permissions: any;
|
||||
environmentIds?: number[] | null; // null = all environments, array = specific envs
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Simple role assignment - just the role ID (env scope is on the role itself)
|
||||
interface RoleAssignment {
|
||||
roleId: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
user?: LocalUser | null;
|
||||
roles: Role[];
|
||||
isEnterprise?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
user = null,
|
||||
roles = [],
|
||||
isEnterprise = false,
|
||||
onClose,
|
||||
onSaved
|
||||
}: Props = $props();
|
||||
|
||||
const isEditing = $derived(user !== null);
|
||||
|
||||
// Form state
|
||||
let formUsername = $state('');
|
||||
let formEmail = $state('');
|
||||
let formDisplayName = $state('');
|
||||
let formPassword = $state('');
|
||||
let formPasswordRepeat = $state('');
|
||||
let formRoleAssignments = $state<RoleAssignment[]>([]);
|
||||
let formError = $state('');
|
||||
let formErrors = $state<{ username?: string; password?: string; passwordRepeat?: string }>({});
|
||||
let formSaving = $state(false);
|
||||
let mfaDisabling = $state(false);
|
||||
|
||||
function resetForm() {
|
||||
formUsername = '';
|
||||
formEmail = '';
|
||||
formDisplayName = '';
|
||||
formPassword = '';
|
||||
formPasswordRepeat = '';
|
||||
formRoleAssignments = [];
|
||||
formError = '';
|
||||
formErrors = {};
|
||||
formSaving = false;
|
||||
mfaDisabling = false;
|
||||
}
|
||||
|
||||
async function handleMfaToggle() {
|
||||
if (!user || !user.mfaEnabled) return;
|
||||
mfaDisabling = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/mfa`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('MFA disabled for user');
|
||||
user.mfaEnabled = false;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || 'Failed to disable MFA');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to disable MFA');
|
||||
} finally {
|
||||
mfaDisabling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize form when user changes or modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (user) {
|
||||
formUsername = user.username;
|
||||
formEmail = user.email || '';
|
||||
formDisplayName = user.displayName || '';
|
||||
formPassword = '';
|
||||
formPasswordRepeat = '';
|
||||
formRoleAssignments = [];
|
||||
formError = '';
|
||||
formErrors = {};
|
||||
formSaving = false;
|
||||
// Fetch user's current roles
|
||||
fetchUserRoles(user.id);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchUserRoles(userId: number) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/roles`);
|
||||
if (response.ok) {
|
||||
const userRoles = await response.json();
|
||||
// Get unique role IDs (user just has roles assigned, env scope is on role)
|
||||
const uniqueRoleIds = [...new Set(userRoles.map((ur: any) => ur.roleId))];
|
||||
formRoleAssignments = uniqueRoleIds.map(roleId => ({ roleId: roleId as number }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user roles:', error);
|
||||
toast.error('Failed to fetch user roles');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUserRoles(userId: number) {
|
||||
try {
|
||||
// Get current assignments from server
|
||||
const currentResponse = await fetch(`/api/users/${userId}/roles`);
|
||||
if (!currentResponse.ok) return;
|
||||
const currentRoles = await currentResponse.json();
|
||||
const currentRoleIds = [...new Set(currentRoles.map((c: any) => c.roleId))];
|
||||
const targetRoleIds = formRoleAssignments.map(a => a.roleId);
|
||||
|
||||
// Remove roles that are no longer assigned
|
||||
for (const roleId of currentRoleIds) {
|
||||
if (!targetRoleIds.includes(roleId as number)) {
|
||||
await fetch(`/api/users/${userId}/roles`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roleId })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add new role assignments
|
||||
for (const roleId of targetRoleIds) {
|
||||
if (!currentRoleIds.includes(roleId)) {
|
||||
await fetch(`/api/users/${userId}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roleId })
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync user roles:', error);
|
||||
toast.error('Failed to sync user roles');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRole(roleId: number, checked: boolean, _isSystem: boolean) {
|
||||
if (checked) {
|
||||
formRoleAssignments = [...formRoleAssignments, { roleId }];
|
||||
} else {
|
||||
formRoleAssignments = formRoleAssignments.filter(a => a.roleId !== roleId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
formErrors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!formUsername.trim()) {
|
||||
formErrors.username = 'Username is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!formPassword.trim()) {
|
||||
formErrors.password = 'Password is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formPassword !== formPasswordRepeat) {
|
||||
formErrors.passwordRepeat = 'Passwords do not match';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: formUsername.trim(),
|
||||
email: formEmail.trim() || undefined,
|
||||
displayName: formDisplayName.trim() || undefined,
|
||||
password: formPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Sync roles for the new user (Enterprise mode)
|
||||
if (data.id && isEnterprise) {
|
||||
await syncUserRoles(data.id);
|
||||
}
|
||||
|
||||
open = false;
|
||||
onSaved();
|
||||
toast.success('User created');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
formError = data.error || 'Failed to create user';
|
||||
toast.error(formError);
|
||||
}
|
||||
} catch {
|
||||
formError = 'Failed to create user';
|
||||
toast.error('Failed to create user');
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser() {
|
||||
formErrors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!user || !formUsername.trim()) {
|
||||
formErrors.username = 'Username is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formPassword.trim() && formPassword !== formPasswordRepeat) {
|
||||
formErrors.passwordRepeat = 'Passwords do not match';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
try {
|
||||
const body: any = {
|
||||
username: formUsername.trim(),
|
||||
email: formEmail.trim() || null,
|
||||
displayName: formDisplayName.trim() || null
|
||||
};
|
||||
if (formPassword.trim()) {
|
||||
body.password = formPassword;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${user!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await syncUserRoles(user!.id);
|
||||
open = false;
|
||||
onSaved();
|
||||
toast.success('User updated');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
formError = data.error || 'Failed to update user';
|
||||
toast.error(formError);
|
||||
}
|
||||
} catch {
|
||||
formError = 'Failed to update user';
|
||||
toast.error('Failed to update user');
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Get icon component for a role based on its name
|
||||
function getRoleIcon(roleName: string): typeof Crown {
|
||||
const name = roleName.toLowerCase();
|
||||
if (name.includes('admin')) return Crown;
|
||||
if (name.includes('operator')) return Wrench;
|
||||
if (name.includes('viewer') || name.includes('view') || name.includes('read')) return Eye;
|
||||
return Tag;
|
||||
}
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (isEditing) {
|
||||
updateUser();
|
||||
} else {
|
||||
createUser();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if isEditing}
|
||||
<Pencil class="w-5 h-5" />
|
||||
Edit user
|
||||
{:else}
|
||||
<UserPlus class="w-5 h-5" />
|
||||
Add user
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="space-y-5">
|
||||
{#if formError}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{formError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{#if user?.isSso}
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<ShieldCheck class="w-4 h-4 text-yellow-600 flex-shrink-0" />
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-500">
|
||||
SSO user - profile synced from identity provider
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Details Section -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<User class="w-4 h-4" />
|
||||
User details
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Username {#if !isEditing}<span class="text-destructive">*</span>{/if}</Label>
|
||||
<Input
|
||||
bind:value={formUsername}
|
||||
placeholder={isEditing ? 'admin' : 'johndoe'}
|
||||
autocomplete="off"
|
||||
disabled={user?.isSso}
|
||||
class="{user?.isSso ? 'opacity-60' : ''} {formErrors.username ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
||||
oninput={() => formErrors.username = undefined}
|
||||
/>
|
||||
{#if formErrors.username}
|
||||
<p class="text-xs text-destructive">{formErrors.username}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
bind:value={formEmail}
|
||||
placeholder={isEditing ? 'admin@example.com' : 'john@example.com'}
|
||||
disabled={user?.isSso}
|
||||
class={user?.isSso ? 'opacity-60' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Display name</Label>
|
||||
<Input
|
||||
bind:value={formDisplayName}
|
||||
placeholder={isEditing ? 'Administrator' : 'John Doe'}
|
||||
disabled={user?.isSso}
|
||||
class={user?.isSso ? 'opacity-60' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Section -->
|
||||
{#if !user?.isSso}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound class="w-4 h-4" />
|
||||
Password
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
{#if isEditing}
|
||||
<Label>New password <span class="text-muted-foreground text-xs">(leave blank to keep current)</span></Label>
|
||||
{:else}
|
||||
<Label>Password <span class="text-destructive">*</span></Label>
|
||||
{/if}
|
||||
<Input
|
||||
type="password"
|
||||
bind:value={formPassword}
|
||||
placeholder={isEditing ? 'Enter new password' : 'Enter password'}
|
||||
autocomplete="new-password"
|
||||
class={formErrors.password ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => formErrors.password = undefined}
|
||||
/>
|
||||
<PasswordStrengthIndicator password={formPassword} />
|
||||
{#if formErrors.password}
|
||||
<p class="text-xs text-destructive">{formErrors.password}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#if isEditing}
|
||||
<Label>Confirm password</Label>
|
||||
{:else}
|
||||
<Label>Confirm password <span class="text-destructive">*</span></Label>
|
||||
{/if}
|
||||
<Input
|
||||
type="password"
|
||||
bind:value={formPasswordRepeat}
|
||||
placeholder={isEditing ? 'Repeat new password' : 'Repeat password'}
|
||||
autocomplete="new-password"
|
||||
class={formErrors.passwordRepeat ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => formErrors.passwordRepeat = undefined}
|
||||
/>
|
||||
{#if formErrors.passwordRepeat}
|
||||
<p class="text-xs text-destructive">{formErrors.passwordRepeat}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MFA Section (Enterprise only, editing existing user with MFA enabled) -->
|
||||
{#if isEnterprise && isEditing && user && !user.isSso}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<Smartphone class="w-4 h-4" />
|
||||
Two-factor authentication
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium">MFA status</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if user.mfaEnabled}
|
||||
User has MFA configured
|
||||
{:else}
|
||||
User has not configured MFA
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={user.mfaEnabled}
|
||||
disabled={!user.mfaEnabled || mfaDisabling}
|
||||
onchange={handleMfaToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Role Assignment Section -->
|
||||
{#if isEnterprise}
|
||||
{@const systemRoles = roles.filter(r => r.isSystem)}
|
||||
{@const customRoles = roles.filter(r => !r.isSystem)}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="text-sm">Roles</Label>
|
||||
<p class="text-xs text-muted-foreground">Assign roles to this user. Environment scope is configured on the role itself.</p>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg divide-y max-h-[240px] overflow-y-auto">
|
||||
<!-- System Roles -->
|
||||
{#if systemRoles.length > 0}
|
||||
<div class="p-3 bg-muted/30">
|
||||
<p class="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
System roles
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each systemRoles as role}
|
||||
{@const isAssigned = formRoleAssignments.some(a => a.roleId === role.id)}
|
||||
{@const RoleIcon = getRoleIcon(role.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-all {isAssigned ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-background hover:bg-muted border-border'}"
|
||||
onclick={() => toggleRole(role.id, !isAssigned, role.isSystem)}
|
||||
>
|
||||
{#if isAssigned}
|
||||
<Check class="w-4 h-4 flex-shrink-0" />
|
||||
{:else}
|
||||
<RoleIcon class="w-4 h-4 flex-shrink-0 opacity-50" />
|
||||
{/if}
|
||||
<span class="truncate">{role.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Custom Roles -->
|
||||
{#if customRoles.length > 0}
|
||||
<div class="p-3">
|
||||
<p class="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<Globe class="w-3.5 h-3.5" />
|
||||
Custom roles
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each customRoles as role}
|
||||
{@const isAssigned = formRoleAssignments.some(a => a.roleId === role.id)}
|
||||
{@const envCount = role.environmentIds?.length ?? 0}
|
||||
{@const isGlobal = role.environmentIds === null}
|
||||
{@const RoleIcon = getRoleIcon(role.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-all text-left {isAssigned ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'bg-background hover:bg-muted border-border'}"
|
||||
onclick={() => toggleRole(role.id, !isAssigned, role.isSystem)}
|
||||
>
|
||||
{#if isAssigned}
|
||||
<Check class="w-4 h-4 flex-shrink-0" />
|
||||
{:else}
|
||||
<RoleIcon class="w-4 h-4 flex-shrink-0 opacity-50" />
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="truncate block">{role.name}</span>
|
||||
<span class="text-2xs opacity-70 flex items-center gap-1">
|
||||
{#if isGlobal}
|
||||
<Globe class="w-2.5 h-2.5" />
|
||||
All environments
|
||||
{:else}
|
||||
{envCount} environment{envCount !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="p-4 text-center text-sm text-muted-foreground">
|
||||
No roles defined yet
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
All users have full access to all environments.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Crown class="w-3 h-3 text-amber-500" />
|
||||
Upgrade to Enterprise for role-based access control.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
{#if isEditing}
|
||||
<Button variant="outline" type="button" onclick={handleClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Check class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Save
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" type="button" onclick={handleClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<UserPlus class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Create user
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
490
routes/settings/auth/users/UsersSubTab.svelte
Normal file
490
routes/settings/auth/users/UsersSubTab.svelte
Normal file
@@ -0,0 +1,490 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import {
|
||||
Users,
|
||||
User,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
AlertTriangle,
|
||||
Crown,
|
||||
Wrench,
|
||||
Eye,
|
||||
Tag,
|
||||
KeyRound,
|
||||
Network,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown
|
||||
} from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { EmptyState } from '$lib/components/ui/empty-state';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import UserModal from './UserModal.svelte';
|
||||
|
||||
const MAX_VISIBLE_ROLES = 5;
|
||||
|
||||
// Search and sort state
|
||||
type SortField = 'username' | 'email' | 'provider';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
let searchQuery = $state('');
|
||||
let sortField = $state<SortField>('username');
|
||||
let sortDirection = $state<SortDirection>('asc');
|
||||
|
||||
interface UserRole {
|
||||
id: number;
|
||||
name: string;
|
||||
environmentId?: number | null;
|
||||
}
|
||||
|
||||
interface LocalUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
mfaEnabled: boolean;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
isSso: boolean;
|
||||
authProvider?: string;
|
||||
lastLogin?: string;
|
||||
createdAt: string;
|
||||
roles?: UserRole[];
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
permissions: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
let { roles }: Props = $props();
|
||||
|
||||
// Local users state
|
||||
let localUsers = $state<LocalUser[]>([]);
|
||||
let usersLoading = $state(true);
|
||||
let showUserModal = $state(false);
|
||||
let editingUser = $state<LocalUser | null>(null);
|
||||
let confirmDeleteUserId = $state<number | null>(null);
|
||||
let showLastAdminWarning = $state(false);
|
||||
let lastAdminDeleteUserId = $state<number | null>(null);
|
||||
|
||||
async function fetchUsers() {
|
||||
usersLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
if (response.ok) {
|
||||
localUsers = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error('Failed to fetch users');
|
||||
} finally {
|
||||
usersLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openUserModal(user: LocalUser | null) {
|
||||
editingUser = user;
|
||||
showUserModal = true;
|
||||
}
|
||||
|
||||
function handleUserModalClose() {
|
||||
showUserModal = false;
|
||||
editingUser = null;
|
||||
}
|
||||
|
||||
async function handleUserModalSaved() {
|
||||
showUserModal = false;
|
||||
editingUser = null;
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function deleteLocalUser(userId: number, confirmDisableAuth = false) {
|
||||
try {
|
||||
const url = confirmDisableAuth
|
||||
? `/api/users/${userId}?confirmDisableAuth=true`
|
||||
: `/api/users/${userId}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
await fetchUsers();
|
||||
if (data.authDisabled) {
|
||||
toast.success('User deleted. Authentication has been disabled.');
|
||||
} else {
|
||||
toast.success('User deleted');
|
||||
}
|
||||
showLastAdminWarning = false;
|
||||
lastAdminDeleteUserId = null;
|
||||
} else if (response.status === 409) {
|
||||
// Check if this is the last admin warning
|
||||
const data = await response.json();
|
||||
if (data.isLastAdmin) {
|
||||
// Show last admin warning dialog
|
||||
lastAdminDeleteUserId = userId;
|
||||
showLastAdminWarning = true;
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to delete user');
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || 'Failed to delete user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
toast.error('Failed to delete user');
|
||||
} finally {
|
||||
confirmDeleteUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmLastAdminDelete() {
|
||||
if (lastAdminDeleteUserId) {
|
||||
deleteLocalUser(lastAdminDeleteUserId, true);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelLastAdminDelete() {
|
||||
showLastAdminWarning = false;
|
||||
lastAdminDeleteUserId = null;
|
||||
}
|
||||
|
||||
// Get icon component for a role based on its name
|
||||
function getRoleIcon(roleName: string): typeof Crown {
|
||||
const name = roleName.toLowerCase();
|
||||
if (name.includes('admin')) return Crown;
|
||||
if (name.includes('operator')) return Wrench;
|
||||
if (name.includes('viewer') || name.includes('view') || name.includes('read')) return Eye;
|
||||
return Tag;
|
||||
}
|
||||
|
||||
// Get provider display info
|
||||
function getProviderInfo(user: LocalUser): { icon: typeof KeyRound; label: string; class: string; sortKey: string } {
|
||||
if (!user.isSso) {
|
||||
return { icon: KeyRound, label: 'Local', class: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/30', sortKey: 'local' };
|
||||
}
|
||||
const providerParts = user.authProvider?.split(':') || [];
|
||||
const providerType = providerParts[0]?.toLowerCase() || 'sso';
|
||||
const providerName = providerParts[1] || '';
|
||||
|
||||
if (providerType === 'ldap') {
|
||||
return { icon: Network, label: providerName || 'LDAP', class: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30', sortKey: 'ldap' };
|
||||
}
|
||||
return { icon: ShieldCheck, label: providerName || 'OIDC', class: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/30', sortKey: 'oidc' };
|
||||
}
|
||||
|
||||
// Filter and sort users
|
||||
const filteredAndSortedUsers = $derived.by(() => {
|
||||
let result = localUsers;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(user =>
|
||||
user.username.toLowerCase().includes(query) ||
|
||||
(user.email?.toLowerCase().includes(query)) ||
|
||||
(user.displayName?.toLowerCase().includes(query)) ||
|
||||
(user.roles?.some(r => r.name.toLowerCase().includes(query)))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
let aVal: string, bVal: string;
|
||||
switch (sortField) {
|
||||
case 'username':
|
||||
aVal = a.username.toLowerCase();
|
||||
bVal = b.username.toLowerCase();
|
||||
break;
|
||||
case 'email':
|
||||
aVal = (a.email || '').toLowerCase();
|
||||
bVal = (b.email || '').toLowerCase();
|
||||
break;
|
||||
case 'provider':
|
||||
aVal = getProviderInfo(a).sortKey;
|
||||
bVal = getProviderInfo(b).sortKey;
|
||||
break;
|
||||
default:
|
||||
aVal = a.username.toLowerCase();
|
||||
bVal = b.username.toLowerCase();
|
||||
}
|
||||
const cmp = aVal.localeCompare(bVal);
|
||||
return sortDirection === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortDirection = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<Card.Root class="flex flex-col flex-1 min-h-0">
|
||||
<Card.Header class="flex-shrink-0 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title class="text-sm font-medium flex items-center gap-2">
|
||||
<Users class="w-4 h-4" />
|
||||
Users
|
||||
</Card.Title>
|
||||
<p class="text-xs text-muted-foreground mt-1">Manage user accounts for local authentication, SSO, and LDAP.</p>
|
||||
</div>
|
||||
{#if $canAccess('users', 'create')}
|
||||
<Button size="sm" onclick={() => openUserModal(null)}>
|
||||
<UserPlus class="w-4 h-4 mr-1" />
|
||||
Add user
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 flex flex-col min-h-0">
|
||||
{#if usersLoading}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if localUsers.length === 0}
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No users configured"
|
||||
description="Create the first user to enable login"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Filter bar -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
bind:value={searchQuery}
|
||||
class="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground ml-auto">
|
||||
<span>{filteredAndSortedUsers.length} of {localUsers.length} users</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Table -->
|
||||
<div class="flex-1 min-h-0 overflow-auto rounded-lg border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr class="border-b">
|
||||
<th class="text-left py-1.5 px-3 font-medium w-[25%]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onclick={() => toggleSort('username')}
|
||||
>
|
||||
User
|
||||
{#if sortField === 'username'}
|
||||
{#if sortDirection === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="w-3 h-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left py-1.5 px-3 font-medium w-[25%]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onclick={() => toggleSort('email')}
|
||||
>
|
||||
Email
|
||||
{#if sortField === 'email'}
|
||||
{#if sortDirection === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="w-3 h-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left py-1.5 px-3 font-medium w-[8%]">MFA</th>
|
||||
{#if $licenseStore.isEnterprise}
|
||||
<th class="text-left py-1.5 px-3 font-medium w-[25%]">Roles</th>
|
||||
{/if}
|
||||
<th class="text-left py-1.5 px-3 font-medium w-[15%]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onclick={() => toggleSort('provider')}
|
||||
>
|
||||
Provider
|
||||
{#if sortField === 'provider'}
|
||||
{#if sortDirection === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="w-3 h-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-right py-1.5 px-3 font-medium w-[10%]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredAndSortedUsers as user}
|
||||
{@const provider = getProviderInfo(user)}
|
||||
{@const ProviderIcon = provider.icon}
|
||||
{@const visibleRoles = user.roles?.slice(0, MAX_VISIBLE_ROLES) || []}
|
||||
{@const hiddenRolesCount = (user.roles?.length || 0) - MAX_VISIBLE_ROLES}
|
||||
<tr class="border-b border-muted hover:bg-muted/30 transition-colors">
|
||||
<!-- User -->
|
||||
<td class="py-2 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User class="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium">{user.username}</span>
|
||||
{#if !user.isActive}
|
||||
<Badge variant="destructive" class="text-2xs px-1 py-0 h-4">Disabled</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="py-2 px-3">
|
||||
<span class="text-muted-foreground truncate block">{user.email || '—'}</span>
|
||||
</td>
|
||||
<!-- MFA -->
|
||||
<td class="py-2 px-3">
|
||||
{#if user.mfaEnabled}
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 h-4 gap-1 rounded-sm bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
<Shield class="w-2.5 h-2.5" />
|
||||
Enabled
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Roles (Enterprise only) -->
|
||||
{#if $licenseStore.isEnterprise}
|
||||
<td class="py-2 px-3">
|
||||
{#if user.roles && user.roles.length > 0}
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
{#each visibleRoles as role}
|
||||
{@const RoleIcon = getRoleIcon(role.name)}
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 h-4 gap-1 rounded-sm bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30">
|
||||
<RoleIcon class="w-2.5 h-2.5" />
|
||||
{role.name}
|
||||
</Badge>
|
||||
{/each}
|
||||
{#if hiddenRolesCount > 0}
|
||||
<span class="text-2xs text-muted-foreground">+{hiddenRolesCount} more</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
<!-- Provider -->
|
||||
<td class="py-2 px-3">
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 h-4 gap-1 rounded-sm {provider.class}">
|
||||
<ProviderIcon class="w-2.5 h-2.5" />
|
||||
{provider.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="py-2 px-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{#if $canAccess('users', 'edit')}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={() => openUserModal(user)}
|
||||
>
|
||||
<Pencil class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $canAccess('users', 'delete')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteUserId === user.id}
|
||||
action="Delete"
|
||||
itemType="user"
|
||||
itemName={user.username}
|
||||
onConfirm={() => deleteLocalUser(user.id)}
|
||||
onOpenChange={(open) => { if (!open) confirmDeleteUserId = null; else confirmDeleteUserId = user.id; }}
|
||||
>
|
||||
<span class="inline-flex items-center justify-center h-7 w-7 rounded-md hover:bg-accent hover:text-accent-foreground">
|
||||
<Trash2 class="w-3.5 h-3.5 text-destructive" />
|
||||
</span>
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={$licenseStore.isEnterprise ? 6 : 5} class="py-8 text-center text-muted-foreground">
|
||||
<Search class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No users found matching "{searchQuery}"</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<UserModal
|
||||
bind:open={showUserModal}
|
||||
user={editingUser}
|
||||
{roles}
|
||||
isEnterprise={$licenseStore.isEnterprise}
|
||||
onClose={handleUserModalClose}
|
||||
onSaved={handleUserModalSaved}
|
||||
/>
|
||||
|
||||
<!-- Last Admin Warning Dialog -->
|
||||
<Dialog.Root bind:open={showLastAdminWarning}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="w-5 h-5" />
|
||||
Delete last admin?
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-left">
|
||||
This is the only admin account. Deleting it will <strong>disable authentication</strong> and allow anyone to access Dockhand without logging in.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={cancelLastAdminDelete}>Cancel</Button>
|
||||
<Button variant="destructive" onclick={confirmLastAdminDelete}>
|
||||
<Trash2 class="w-4 h-4 mr-1" />
|
||||
Delete and disable auth
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user