feat: implement user management features including creation, updating, and deletion of users
- Added user management routes and logic in `+page.server.ts` for creating, updating, resetting passwords, and deleting users. - Created a user management interface in `+page.svelte` with dialogs for user actions. - Integrated password validation and hashing using `@node-rs/argon2`. - Updated database schema to include a `user` table with necessary fields. - Seeded a default admin user during database migration if no users exist. - Added necessary dependencies in `package.json`.
This commit is contained in:
@@ -2,13 +2,25 @@
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { statusColors, statusLabels, formatDate } from '$lib/utils';
|
||||
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } from 'lucide-svelte';
|
||||
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw, Inbox } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let searchInput = $state('');
|
||||
let statusFilter = $state('');
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'in_review', label: 'In Review' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' }
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
searchInput = data.filters.search;
|
||||
statusFilter = data.filters.status;
|
||||
@@ -48,7 +60,7 @@
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-50 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search hostname, user, name, email..."
|
||||
bind:value={searchInput}
|
||||
@@ -56,97 +68,116 @@
|
||||
class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={applyFilters}
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="new">New</option>
|
||||
<option value="in_review">In Review</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={applyFilters}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Select.Root type="single" bind:value={statusFilter} onValueChange={() => applyFilters()}>
|
||||
<Select.Trigger class="w-37.5">
|
||||
{statusOptions.find((o) => o.value === statusFilter)?.label || 'All statuses'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="All statuses" />
|
||||
{#each statusOptions as option}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button onclick={applyFilters}>
|
||||
<Filter class="h-4 w-4" />
|
||||
Filter
|
||||
</button>
|
||||
<button
|
||||
onclick={refreshReports}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
</Button>
|
||||
<Button onclick={refreshReports}>
|
||||
<RefreshCcw class="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</Button>
|
||||
{#if data.filters.search || data.filters.status}
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="rounded-md border border-input px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Button variant="outline" onclick={clearFilters}>
|
||||
Clear
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-muted/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">ID</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Hostname</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Reporter</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Files</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.reports as report (report.id)}
|
||||
<tr
|
||||
class="border-b border-border transition-colors hover:bg-muted/30 cursor-pointer"
|
||||
onclick={() => goto(`/reports/${report.id}`)}
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-muted-foreground">#{report.id}</td>
|
||||
<td class="px-4 py-3">{report.hostname || '—'}</td>
|
||||
<td class="px-4 py-3">{report.os_user || '—'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>{report.name}</div>
|
||||
<div class="text-xs text-muted-foreground">{report.email}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[report.status]}"
|
||||
>
|
||||
{statusLabels[report.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if report.file_count > 0}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Paperclip class="h-3.5 w-3.5" />
|
||||
{report.file_count}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{formatDate(report.created_at)}</td>
|
||||
</tr>
|
||||
{#if data.reports.length === 0}
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<Empty.Root class="p-8">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<Inbox class="h-10 w-10 text-muted-foreground" />
|
||||
</Empty.Media>
|
||||
<Empty.Title>No reports found</Empty.Title>
|
||||
<Empty.Description>
|
||||
There are no bug reports matching your current filters.
|
||||
</Empty.Description>
|
||||
</Empty.Header>
|
||||
{#if data.filters.search || data.filters.status}
|
||||
<Empty.Content>
|
||||
<Button variant="outline" onclick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-muted-foreground">
|
||||
No reports found.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Empty.Content>
|
||||
<Button variant="outline" onclick={refreshReports}>
|
||||
<RefreshCcw class="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
{/if}
|
||||
</Empty.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[80px]">ID</Table.Head>
|
||||
<Table.Head>Hostname</Table.Head>
|
||||
<Table.Head>User</Table.Head>
|
||||
<Table.Head>Reporter</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Files</Table.Head>
|
||||
<Table.Head class="text-right">Created</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data.reports as report (report.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
onclick={() => goto(`/reports/${report.id}`)}
|
||||
>
|
||||
<Table.Cell class="font-mono text-muted-foreground">#{report.id}</Table.Cell>
|
||||
<Table.Cell>{report.hostname || '—'}</Table.Cell>
|
||||
<Table.Cell>{report.os_user || '—'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="font-medium">{report.name}</div>
|
||||
<div class="text-xs text-muted-foreground">{report.email}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[
|
||||
report.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[report.status]}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if report.file_count > 0}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Paperclip class="h-3.5 w-3.5" />
|
||||
{report.file_count}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right text-muted-foreground">
|
||||
{formatDate(report.created_at)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.pagination.totalPages > 1}
|
||||
@@ -158,35 +189,35 @@
|
||||
)} of {data.pagination.total} reports
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => goToPage(data.pagination.page - 1)}
|
||||
disabled={data.pagination.page <= 1}
|
||||
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
{#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
|
||||
{#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
|
||||
<button
|
||||
<Button
|
||||
variant={p === data.pagination.page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onclick={() => goToPage(p)}
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
|
||||
data.pagination.page
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-input hover:bg-accent'}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</Button>
|
||||
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
|
||||
<span class="px-1 text-muted-foreground">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => goToPage(data.pagination.page + 1)}
|
||||
disabled={data.pagination.page >= data.pagination.totalPages}
|
||||
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user