@podosoft/podokit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +20 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/tsconfig.json +14 -5
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +3 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/api.ts +5 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +19 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +43 -4
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +8 -3
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +12 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +5 -0
- package/dist/templates/fullstack-nest-svelte/package.json +11 -3
- package/dist/templates/fullstack-nest-svelte/tests/README.md +15 -0
- package/dist/templates/fullstack-nest-svelte/tests/api/health.api.spec.ts +14 -0
- package/dist/templates/fullstack-nest-svelte/tests/package.json +14 -0
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +25 -0
- package/dist/templates/fullstack-nest-svelte/tests/ui/smoke.ui.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +59 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/language-switch.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +40 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible.svelte +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/index.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/index.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-content.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-item.svelte +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-link.svelte +42 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next-button.svelte +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next.svelte +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-prev-button.svelte +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-previous.svelte +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination.svelte +28 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/index.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-close.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-content.svelte +55 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-description.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-header.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-title.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/constants.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/context.svelte.ts +81 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/index.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte +37 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte +53 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte +19 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar.svelte +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/user-avatar.svelte +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/hooks/is-mobile.svelte.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +359 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.svelte +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.server.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +374 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +78 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +406 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +59 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +51 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +22 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +48 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +134 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +45 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +129 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +19 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +41 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +59 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +34 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +70 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-log.entity.ts +29 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.controller.ts +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.decorator.ts +23 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +36 -21
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +36 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +10 -4
- package/dist/templates/modules/audit-log/module.manifest.json +30 -7
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +17 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +26 -3
- package/dist/templates/todo/apps/api/package.json +20 -2
- package/dist/templates/todo/apps/api/tsconfig.json +14 -5
- package/dist/templates/todo/apps/web/package.json +3 -2
- package/dist/templates/todo/apps/web/src/lib/api.ts +5 -0
- package/dist/templates/todo/apps/web/src/lib/server/api.ts +19 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +43 -4
- package/dist/templates/todo/apps/web/src/routes/+page.svelte +19 -18
- package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +12 -0
- package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
- package/dist/templates/todo/dot-gitignore +5 -0
- package/dist/templates/todo/package.json +11 -3
- package/dist/templates/todo/tests/README.md +15 -0
- package/dist/templates/todo/tests/api/health.api.spec.ts +14 -0
- package/dist/templates/todo/tests/package.json +14 -0
- package/dist/templates/todo/tests/playwright.config.ts +25 -0
- package/dist/templates/todo/tests/ui/smoke.ui.spec.ts +8 -0
- package/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/test/health.e2e-spec.ts +0 -23
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/health/+server.ts +0 -12
- package/dist/templates/todo/apps/api/test/health.e2e-spec.ts +0 -23
- package/dist/templates/todo/apps/web/src/routes/api/health/+server.ts +0 -12
- package/dist/templates/todo/apps/web/src/routes/api/todos/+server.ts +0 -24
- package/dist/templates/todo/apps/web/src/routes/api/todos/[id]/+server.ts +0 -24
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "$lib/components/ui/button";
|
|
3
|
+
import * as Table from "$lib/components/ui/table";
|
|
4
|
+
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
|
5
|
+
import TablePagination from "$lib/components/table-pagination.svelte";
|
|
6
|
+
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
|
7
|
+
import { toast } from "svelte-sonner";
|
|
8
|
+
import { api } from "$lib/api";
|
|
9
|
+
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
10
|
+
|
|
11
|
+
const i18n = getI18n();
|
|
12
|
+
|
|
13
|
+
// better-auth has no "list all sessions" endpoint, so fan out listUserSessions
|
|
14
|
+
// over a page of users and flatten. Bounded to USER_LIMIT users — adequate for a
|
|
15
|
+
// starter admin view; paginate/scope further for large deployments.
|
|
16
|
+
const USER_LIMIT = 50;
|
|
17
|
+
type Row = {
|
|
18
|
+
id: string;
|
|
19
|
+
token: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
userName: string;
|
|
22
|
+
userEmail: string;
|
|
23
|
+
userAgent?: string | null;
|
|
24
|
+
ipAddress?: string | null;
|
|
25
|
+
createdAt: string | Date;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PAGE_SIZE = 5;
|
|
29
|
+
let rows = $state<Row[]>([]);
|
|
30
|
+
let page = $state(1);
|
|
31
|
+
let loading = $state(false);
|
|
32
|
+
let busy = $state(false);
|
|
33
|
+
const pagedRows = $derived(rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
|
|
34
|
+
|
|
35
|
+
async function load(): Promise<void> {
|
|
36
|
+
loading = true;
|
|
37
|
+
const { data, error } = await api.auth.admin.listUsers({ query: { limit: USER_LIMIT, offset: 0 } });
|
|
38
|
+
if (error) {
|
|
39
|
+
loading = false;
|
|
40
|
+
toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const users = (data?.users ?? []) as Array<{ id: string; name: string; email: string }>;
|
|
44
|
+
const perUser = await Promise.all(
|
|
45
|
+
users.map(async (u) => {
|
|
46
|
+
const { data: res } = await api.auth.admin.listUserSessions({ userId: u.id });
|
|
47
|
+
const sessions = (res?.sessions ?? []) as Array<Omit<Row, "userId" | "userName" | "userEmail">>;
|
|
48
|
+
return sessions.map((s) => ({ ...s, userId: u.id, userName: u.name, userEmail: u.email }));
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
rows = perUser.flat();
|
|
52
|
+
loading = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function act(promise: Promise<{ error?: { message?: string } | null }>, ok: string): Promise<void> {
|
|
56
|
+
busy = true;
|
|
57
|
+
const { error } = await promise;
|
|
58
|
+
busy = false;
|
|
59
|
+
if (error) toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
|
|
60
|
+
else {
|
|
61
|
+
toast.success(ok);
|
|
62
|
+
await load();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const revoke = (row: Row) =>
|
|
67
|
+
act(api.auth.admin.revokeUserSession({ sessionToken: row.token }), i18n.t.adminSessions.revoked);
|
|
68
|
+
const revokeAll = (row: Row) =>
|
|
69
|
+
act(api.auth.admin.revokeUserSessions({ userId: row.userId }), i18n.t.adminSessions.allRevoked);
|
|
70
|
+
|
|
71
|
+
$effect(() => {
|
|
72
|
+
void load();
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<div class="flex flex-col gap-4">
|
|
77
|
+
<h1 class="text-2xl font-semibold">{i18n.t.adminSessions.title}</h1>
|
|
78
|
+
|
|
79
|
+
<div class="rounded-md border">
|
|
80
|
+
<Table.Root>
|
|
81
|
+
<Table.Header>
|
|
82
|
+
<Table.Row>
|
|
83
|
+
<Table.Head>{i18n.t.adminSessions.user}</Table.Head>
|
|
84
|
+
<Table.Head>{i18n.t.adminSessions.device}</Table.Head>
|
|
85
|
+
<Table.Head>{i18n.t.adminSessions.ip}</Table.Head>
|
|
86
|
+
<Table.Head>{i18n.t.adminSessions.since}</Table.Head>
|
|
87
|
+
<Table.Head class="w-10"></Table.Head>
|
|
88
|
+
</Table.Row>
|
|
89
|
+
</Table.Header>
|
|
90
|
+
<Table.Body>
|
|
91
|
+
{#each pagedRows as row (row.id)}
|
|
92
|
+
<Table.Row>
|
|
93
|
+
<Table.Cell>
|
|
94
|
+
<div class="font-medium">{row.userName}</div>
|
|
95
|
+
<div class="text-muted-foreground text-xs">{row.userEmail}</div>
|
|
96
|
+
</Table.Cell>
|
|
97
|
+
<Table.Cell class="max-w-xs truncate">{row.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
98
|
+
<Table.Cell class="text-muted-foreground">{row.ipAddress ?? "—"}</Table.Cell>
|
|
99
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(row.createdAt)}</Table.Cell>
|
|
100
|
+
<Table.Cell>
|
|
101
|
+
<DropdownMenu.Root>
|
|
102
|
+
<DropdownMenu.Trigger>
|
|
103
|
+
{#snippet child({ props })}
|
|
104
|
+
<Button {...props} variant="ghost" size="icon" disabled={busy}><EllipsisIcon class="size-4" /></Button>
|
|
105
|
+
{/snippet}
|
|
106
|
+
</DropdownMenu.Trigger>
|
|
107
|
+
<DropdownMenu.Content align="end">
|
|
108
|
+
<DropdownMenu.Item onSelect={() => revoke(row)}>{i18n.t.adminSessions.revoke}</DropdownMenu.Item>
|
|
109
|
+
<DropdownMenu.Item onSelect={() => revokeAll(row)}>{i18n.t.adminSessions.revokeAll}</DropdownMenu.Item>
|
|
110
|
+
</DropdownMenu.Content>
|
|
111
|
+
</DropdownMenu.Root>
|
|
112
|
+
</Table.Cell>
|
|
113
|
+
</Table.Row>
|
|
114
|
+
{:else}
|
|
115
|
+
<Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{loading ? "…" : i18n.t.adminSessions.empty}</Table.Cell></Table.Row>
|
|
116
|
+
{/each}
|
|
117
|
+
</Table.Body>
|
|
118
|
+
</Table.Root>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<TablePagination count={rows.length} perPage={PAGE_SIZE} bind:page label={fmt(i18n.t.adminSessions.total, { count: rows.length })} />
|
|
122
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PageServerLoad } from "./$types";
|
|
2
|
+
import { error } from "@sveltejs/kit";
|
|
3
|
+
|
|
4
|
+
export const load: PageServerLoad = ({ locals }) => {
|
|
5
|
+
if (locals.user?.role !== "admin") {
|
|
6
|
+
error(403, "Admins only");
|
|
7
|
+
}
|
|
8
|
+
return { currentUserId: locals.user.id };
|
|
9
|
+
};
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import { Button } from "$lib/components/ui/button";
|
|
4
|
+
import { Input } from "$lib/components/ui/input";
|
|
5
|
+
import { Label } from "$lib/components/ui/label";
|
|
6
|
+
import { Badge } from "$lib/components/ui/badge";
|
|
7
|
+
import { Checkbox } from "$lib/components/ui/checkbox";
|
|
8
|
+
import * as Table from "$lib/components/ui/table";
|
|
9
|
+
import * as Dialog from "$lib/components/ui/dialog";
|
|
10
|
+
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
|
11
|
+
import TablePagination from "$lib/components/table-pagination.svelte";
|
|
12
|
+
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
|
13
|
+
import PlusIcon from "@lucide/svelte/icons/plus";
|
|
14
|
+
import { toast } from "svelte-sonner";
|
|
15
|
+
import { api } from "$lib/api";
|
|
16
|
+
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
17
|
+
import type { PageData } from "./$types";
|
|
18
|
+
|
|
19
|
+
let { data }: { data: PageData } = $props();
|
|
20
|
+
const i18n = getI18n();
|
|
21
|
+
|
|
22
|
+
type Row = { id: string; name: string; email: string; role?: string | null; banned?: boolean | null };
|
|
23
|
+
|
|
24
|
+
const PAGE_SIZE = 5;
|
|
25
|
+
let users = $state<Row[]>([]);
|
|
26
|
+
let total = $state(0);
|
|
27
|
+
let page = $state(1);
|
|
28
|
+
let search = $state("");
|
|
29
|
+
let busy = $state(false);
|
|
30
|
+
|
|
31
|
+
async function load(): Promise<void> {
|
|
32
|
+
const { data: res, error } = await api.auth.admin.listUsers({
|
|
33
|
+
query: {
|
|
34
|
+
limit: PAGE_SIZE,
|
|
35
|
+
offset: (page - 1) * PAGE_SIZE,
|
|
36
|
+
...(search ? { searchField: "email", searchOperator: "contains", searchValue: search } : {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.loadFailed);
|
|
40
|
+
users = (res?.users ?? []) as Row[];
|
|
41
|
+
total = res?.total ?? users.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Impersonate — become the user, then a banner offers "stop impersonating".
|
|
45
|
+
async function impersonate(u: Row): Promise<void> {
|
|
46
|
+
const { error } = await api.auth.admin.impersonateUser({ userId: u.id });
|
|
47
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
48
|
+
await goto("/admin", { invalidateAll: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create user
|
|
52
|
+
let createOpen = $state(false);
|
|
53
|
+
let form = $state({ name: "", email: "", password: "", confirm: "", admin: false });
|
|
54
|
+
let createError = $state("");
|
|
55
|
+
async function createUser(event: Event): Promise<void> {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
if (form.password !== form.confirm) {
|
|
58
|
+
createError = i18n.t.users.passwordMismatch;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
createError = "";
|
|
62
|
+
busy = true;
|
|
63
|
+
const { error } = await api.auth.admin.createUser({
|
|
64
|
+
name: form.name,
|
|
65
|
+
email: form.email,
|
|
66
|
+
password: form.password,
|
|
67
|
+
role: form.admin ? "admin" : "user",
|
|
68
|
+
});
|
|
69
|
+
busy = false;
|
|
70
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
71
|
+
toast.success(i18n.t.users.userCreated);
|
|
72
|
+
createOpen = false;
|
|
73
|
+
form = { name: "", email: "", password: "", confirm: "", admin: false };
|
|
74
|
+
page = 1;
|
|
75
|
+
await load();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Manage user — a two-pane modal mirroring the account settings, admin-scoped.
|
|
79
|
+
type ManageSection = "profile" | "security" | "sessions" | "danger";
|
|
80
|
+
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
81
|
+
let manageOpen = $state(false);
|
|
82
|
+
let mUser = $state<Row | null>(null);
|
|
83
|
+
let mSection = $state<ManageSection>("profile");
|
|
84
|
+
const sections: ManageSection[] = ["profile", "security", "sessions", "danger"];
|
|
85
|
+
// profile
|
|
86
|
+
let mName = $state("");
|
|
87
|
+
let mEmail = $state("");
|
|
88
|
+
let mAdmin = $state(false);
|
|
89
|
+
// security
|
|
90
|
+
let mNewPassword = $state("");
|
|
91
|
+
let mConfirmPassword = $state("");
|
|
92
|
+
let mPwError = $state("");
|
|
93
|
+
let mBanReason = $state("");
|
|
94
|
+
let mBanDays = $state("");
|
|
95
|
+
// sessions
|
|
96
|
+
let mSessions = $state<Session[]>([]);
|
|
97
|
+
let mSessionsPage = $state(1);
|
|
98
|
+
const mPagedSessions = $derived(mSessions.slice((mSessionsPage - 1) * PAGE_SIZE, mSessionsPage * PAGE_SIZE));
|
|
99
|
+
// danger
|
|
100
|
+
let mDeleteArmed = $state(false);
|
|
101
|
+
|
|
102
|
+
async function openManage(u: Row): Promise<void> {
|
|
103
|
+
mUser = u;
|
|
104
|
+
mSection = "profile";
|
|
105
|
+
mName = u.name;
|
|
106
|
+
mEmail = u.email;
|
|
107
|
+
mAdmin = u.role === "admin";
|
|
108
|
+
mNewPassword = "";
|
|
109
|
+
mConfirmPassword = "";
|
|
110
|
+
mPwError = "";
|
|
111
|
+
mBanReason = "";
|
|
112
|
+
mBanDays = "";
|
|
113
|
+
mDeleteArmed = false;
|
|
114
|
+
mSessions = [];
|
|
115
|
+
mSessionsPage = 1;
|
|
116
|
+
manageOpen = true;
|
|
117
|
+
await loadManagedSessions();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadManagedSessions(): Promise<void> {
|
|
121
|
+
if (!mUser) return;
|
|
122
|
+
const { data: res } = await api.auth.admin.listUserSessions({ userId: mUser.id });
|
|
123
|
+
mSessions = (res?.sessions ?? []) as Session[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function saveProfile(event: Event): Promise<void> {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
if (!mUser) return;
|
|
129
|
+
busy = true;
|
|
130
|
+
const { error } = await api.auth.admin.updateUser({
|
|
131
|
+
userId: mUser.id,
|
|
132
|
+
data: { name: mName, email: mEmail, role: mAdmin ? "admin" : "user" },
|
|
133
|
+
});
|
|
134
|
+
busy = false;
|
|
135
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
136
|
+
mUser = { ...mUser, name: mName, email: mEmail, role: mAdmin ? "admin" : "user" };
|
|
137
|
+
toast.success(i18n.t.users.userUpdated);
|
|
138
|
+
await load();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function setPassword(event: Event): Promise<void> {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
if (!mUser) return;
|
|
144
|
+
if (mNewPassword !== mConfirmPassword) {
|
|
145
|
+
mPwError = i18n.t.users.passwordMismatch;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
mPwError = "";
|
|
149
|
+
busy = true;
|
|
150
|
+
const { error } = await api.auth.admin.setUserPassword({ userId: mUser.id, newPassword: mNewPassword });
|
|
151
|
+
busy = false;
|
|
152
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
153
|
+
mNewPassword = "";
|
|
154
|
+
mConfirmPassword = "";
|
|
155
|
+
toast.success(i18n.t.users.passwordSet);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function ban(event: Event): Promise<void> {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (!mUser) return;
|
|
161
|
+
const days = Number(mBanDays);
|
|
162
|
+
busy = true;
|
|
163
|
+
const { error } = await api.auth.admin.banUser({
|
|
164
|
+
userId: mUser.id,
|
|
165
|
+
...(mBanReason ? { banReason: mBanReason } : {}),
|
|
166
|
+
...(days > 0 ? { banExpiresIn: days * 24 * 60 * 60 } : {}),
|
|
167
|
+
});
|
|
168
|
+
busy = false;
|
|
169
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
170
|
+
mUser = { ...mUser, banned: true };
|
|
171
|
+
toast.success(i18n.t.users.userBanned);
|
|
172
|
+
await load();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function unban(): Promise<void> {
|
|
176
|
+
if (!mUser) return;
|
|
177
|
+
busy = true;
|
|
178
|
+
const { error } = await api.auth.admin.unbanUser({ userId: mUser.id });
|
|
179
|
+
busy = false;
|
|
180
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
181
|
+
mUser = { ...mUser, banned: false };
|
|
182
|
+
toast.success(i18n.t.users.userUnbanned);
|
|
183
|
+
await load();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function revokeSession(token: string): Promise<void> {
|
|
187
|
+
busy = true;
|
|
188
|
+
const { error } = await api.auth.admin.revokeUserSession({ sessionToken: token });
|
|
189
|
+
busy = false;
|
|
190
|
+
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
|
|
191
|
+
toast.success(i18n.t.adminSessions.revoked);
|
|
192
|
+
await loadManagedSessions();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function revokeAllSessions(): Promise<void> {
|
|
196
|
+
if (!mUser) return;
|
|
197
|
+
busy = true;
|
|
198
|
+
const { error } = await api.auth.admin.revokeUserSessions({ userId: mUser.id });
|
|
199
|
+
busy = false;
|
|
200
|
+
if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
|
|
201
|
+
toast.success(i18n.t.adminSessions.allRevoked);
|
|
202
|
+
await loadManagedSessions();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function deleteUser(): Promise<void> {
|
|
206
|
+
if (!mUser) return;
|
|
207
|
+
busy = true;
|
|
208
|
+
const { error } = await api.auth.admin.removeUser({ userId: mUser.id });
|
|
209
|
+
busy = false;
|
|
210
|
+
if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
|
|
211
|
+
toast.success(i18n.t.users.userDeleted);
|
|
212
|
+
manageOpen = false;
|
|
213
|
+
await load();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function searchInput(event: Event): void {
|
|
217
|
+
search = (event.target as HTMLInputElement).value;
|
|
218
|
+
page = 1;
|
|
219
|
+
void load();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
$effect(() => {
|
|
223
|
+
void load();
|
|
224
|
+
});
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
<div class="flex flex-col gap-4">
|
|
228
|
+
<div class="flex items-center justify-between gap-2">
|
|
229
|
+
<h1 class="text-2xl font-semibold">{i18n.t.users.title}</h1>
|
|
230
|
+
<Button onclick={() => { createError = ""; createOpen = true; }}><PlusIcon class="size-4" />{i18n.t.users.addUser}</Button>
|
|
231
|
+
</div>
|
|
232
|
+
<Input placeholder={i18n.t.users.search} value={search} oninput={searchInput} class="max-w-xs" />
|
|
233
|
+
|
|
234
|
+
<div class="rounded-md border">
|
|
235
|
+
<Table.Root>
|
|
236
|
+
<Table.Header>
|
|
237
|
+
<Table.Row>
|
|
238
|
+
<Table.Head>{i18n.t.users.name}</Table.Head>
|
|
239
|
+
<Table.Head>{i18n.t.users.email}</Table.Head>
|
|
240
|
+
<Table.Head>{i18n.t.users.role}</Table.Head>
|
|
241
|
+
<Table.Head>{i18n.t.users.status}</Table.Head>
|
|
242
|
+
<Table.Head class="w-10"></Table.Head>
|
|
243
|
+
</Table.Row>
|
|
244
|
+
</Table.Header>
|
|
245
|
+
<Table.Body>
|
|
246
|
+
{#each users as user (user.id)}
|
|
247
|
+
<Table.Row>
|
|
248
|
+
<Table.Cell class="font-medium">{user.name}</Table.Cell>
|
|
249
|
+
<Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
|
|
250
|
+
<Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
|
|
251
|
+
<Table.Cell>
|
|
252
|
+
{#if user.banned}<Badge variant="destructive">{i18n.t.users.banned}</Badge>{:else}<Badge variant="outline">{i18n.t.users.active}</Badge>{/if}
|
|
253
|
+
</Table.Cell>
|
|
254
|
+
<Table.Cell>
|
|
255
|
+
<DropdownMenu.Root>
|
|
256
|
+
<DropdownMenu.Trigger>
|
|
257
|
+
{#snippet child({ props })}
|
|
258
|
+
<Button {...props} variant="ghost" size="icon"><EllipsisIcon class="size-4" /></Button>
|
|
259
|
+
{/snippet}
|
|
260
|
+
</DropdownMenu.Trigger>
|
|
261
|
+
<DropdownMenu.Content align="end">
|
|
262
|
+
<DropdownMenu.Item onSelect={() => openManage(user)}>{i18n.t.users.manage}</DropdownMenu.Item>
|
|
263
|
+
<DropdownMenu.Item disabled={user.id === data.currentUserId} onSelect={() => impersonate(user)}>
|
|
264
|
+
{i18n.t.users.impersonate}
|
|
265
|
+
</DropdownMenu.Item>
|
|
266
|
+
</DropdownMenu.Content>
|
|
267
|
+
</DropdownMenu.Root>
|
|
268
|
+
</Table.Cell>
|
|
269
|
+
</Table.Row>
|
|
270
|
+
{:else}
|
|
271
|
+
<Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{i18n.t.users.empty}</Table.Cell></Table.Row>
|
|
272
|
+
{/each}
|
|
273
|
+
</Table.Body>
|
|
274
|
+
</Table.Root>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<TablePagination
|
|
278
|
+
count={total}
|
|
279
|
+
perPage={PAGE_SIZE}
|
|
280
|
+
bind:page
|
|
281
|
+
label={fmt(i18n.t.users.total, { count: total })}
|
|
282
|
+
onPageChange={() => void load()}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- Create user -->
|
|
287
|
+
<Dialog.Root bind:open={createOpen}>
|
|
288
|
+
<Dialog.Content>
|
|
289
|
+
<Dialog.Header><Dialog.Title>{i18n.t.users.createTitle}</Dialog.Title></Dialog.Header>
|
|
290
|
+
<form onsubmit={createUser} class="flex flex-col gap-3">
|
|
291
|
+
<div class="flex flex-col gap-1"><Label for="c-name">{i18n.t.users.name}</Label><Input id="c-name" bind:value={form.name} required /></div>
|
|
292
|
+
<div class="flex flex-col gap-1"><Label for="c-email">{i18n.t.users.email}</Label><Input id="c-email" type="email" bind:value={form.email} required /></div>
|
|
293
|
+
<div class="flex flex-col gap-1"><Label for="c-password">{i18n.t.users.password}</Label><Input id="c-password" type="password" bind:value={form.password} required /></div>
|
|
294
|
+
<div class="flex flex-col gap-1"><Label for="c-confirm">{i18n.t.users.confirmPassword}</Label><Input id="c-confirm" type="password" bind:value={form.confirm} required /></div>
|
|
295
|
+
{#if createError}<p class="text-destructive text-sm">{createError}</p>{/if}
|
|
296
|
+
<Label class="flex items-center gap-2"><Checkbox bind:checked={form.admin} />{i18n.t.users.makeAdmin}</Label>
|
|
297
|
+
<Dialog.Footer>
|
|
298
|
+
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>{i18n.t.users.cancel}</Button>
|
|
299
|
+
<Button type="submit" disabled={busy}>{i18n.t.users.create}</Button>
|
|
300
|
+
</Dialog.Footer>
|
|
301
|
+
</form>
|
|
302
|
+
</Dialog.Content>
|
|
303
|
+
</Dialog.Root>
|
|
304
|
+
|
|
305
|
+
<!-- Manage user (two-pane) -->
|
|
306
|
+
<Dialog.Root bind:open={manageOpen}>
|
|
307
|
+
<Dialog.Content class="sm:max-w-3xl">
|
|
308
|
+
<Dialog.Header><Dialog.Title class="truncate">{mUser?.email}</Dialog.Title></Dialog.Header>
|
|
309
|
+
<div class="flex flex-col gap-4 sm:flex-row">
|
|
310
|
+
<nav class="flex shrink-0 flex-wrap gap-1 sm:w-40 sm:flex-col">
|
|
311
|
+
{#each sections as key (key)}
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onclick={() => (mSection = key)}
|
|
315
|
+
aria-current={mSection === key ? "page" : undefined}
|
|
316
|
+
class="hover:bg-muted rounded-md px-3 py-2 text-left text-sm font-medium transition-colors {mSection === key ? 'bg-muted' : 'text-muted-foreground'}"
|
|
317
|
+
>
|
|
318
|
+
{i18n.t.account.nav[key]}
|
|
319
|
+
</button>
|
|
320
|
+
{/each}
|
|
321
|
+
</nav>
|
|
322
|
+
|
|
323
|
+
<div class="h-[26rem] min-w-0 flex-1 overflow-y-auto">
|
|
324
|
+
{#if mSection === "profile"}
|
|
325
|
+
<form class="flex flex-col gap-3" onsubmit={saveProfile}>
|
|
326
|
+
<div class="flex flex-col gap-1"><Label for="m-name">{i18n.t.users.name}</Label><Input id="m-name" bind:value={mName} required /></div>
|
|
327
|
+
<div class="flex flex-col gap-1"><Label for="m-email">{i18n.t.users.email}</Label><Input id="m-email" type="email" bind:value={mEmail} required /></div>
|
|
328
|
+
<Label class="flex items-center gap-2"><Checkbox bind:checked={mAdmin} />{i18n.t.users.makeAdmin}</Label>
|
|
329
|
+
{#if mUser?.banned}<Badge variant="destructive" class="w-fit">{i18n.t.users.banned}</Badge>{/if}
|
|
330
|
+
<Button type="submit" class="w-fit" disabled={busy}>{i18n.t.users.save}</Button>
|
|
331
|
+
</form>
|
|
332
|
+
{:else if mSection === "security"}
|
|
333
|
+
<div class="flex flex-col gap-6">
|
|
334
|
+
<form class="flex flex-col gap-3" onsubmit={setPassword}>
|
|
335
|
+
<div class="flex flex-col gap-1"><Label for="m-pw">{i18n.t.users.newPassword}</Label><Input id="m-pw" type="password" bind:value={mNewPassword} required /></div>
|
|
336
|
+
<div class="flex flex-col gap-1"><Label for="m-pw2">{i18n.t.users.confirmNewPassword}</Label><Input id="m-pw2" type="password" bind:value={mConfirmPassword} required /></div>
|
|
337
|
+
{#if mPwError}<p class="text-destructive text-sm">{mPwError}</p>{/if}
|
|
338
|
+
<Button type="submit" class="w-fit" disabled={busy}>{i18n.t.users.setPassword}</Button>
|
|
339
|
+
</form>
|
|
340
|
+
<div class="border-t pt-4">
|
|
341
|
+
{#if mUser?.banned}
|
|
342
|
+
<Button variant="outline" disabled={busy} onclick={unban}>{i18n.t.users.unban}</Button>
|
|
343
|
+
{:else}
|
|
344
|
+
<form class="flex flex-col gap-3" onsubmit={ban}>
|
|
345
|
+
<div class="flex flex-col gap-1"><Label for="m-reason">{i18n.t.users.banReason}</Label><Input id="m-reason" bind:value={mBanReason} /></div>
|
|
346
|
+
<div class="flex flex-col gap-1"><Label for="m-days">{i18n.t.users.banExpiryDays}</Label><Input id="m-days" type="number" min="0" bind:value={mBanDays} /></div>
|
|
347
|
+
<Button type="submit" variant="destructive" class="w-fit" disabled={busy}>{i18n.t.users.ban}</Button>
|
|
348
|
+
</form>
|
|
349
|
+
{/if}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
{:else if mSection === "sessions"}
|
|
353
|
+
<div class="flex flex-col gap-3">
|
|
354
|
+
<div class="flex justify-end">
|
|
355
|
+
<Button variant="outline" size="sm" disabled={busy || mSessions.length === 0} onclick={revokeAllSessions}>{i18n.t.adminSessions.revokeAll}</Button>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="rounded-md border">
|
|
358
|
+
<Table.Root>
|
|
359
|
+
<Table.Header>
|
|
360
|
+
<Table.Row>
|
|
361
|
+
<Table.Head>{i18n.t.adminSessions.device}</Table.Head>
|
|
362
|
+
<Table.Head>{i18n.t.adminSessions.ip}</Table.Head>
|
|
363
|
+
<Table.Head>{i18n.t.adminSessions.since}</Table.Head>
|
|
364
|
+
<Table.Head class="w-10"></Table.Head>
|
|
365
|
+
</Table.Row>
|
|
366
|
+
</Table.Header>
|
|
367
|
+
<Table.Body>
|
|
368
|
+
{#each mPagedSessions as s (s.id)}
|
|
369
|
+
<Table.Row>
|
|
370
|
+
<Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
371
|
+
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
372
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
373
|
+
<Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
|
|
374
|
+
</Table.Row>
|
|
375
|
+
{:else}
|
|
376
|
+
<Table.Row><Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">{i18n.t.adminSessions.empty}</Table.Cell></Table.Row>
|
|
377
|
+
{/each}
|
|
378
|
+
</Table.Body>
|
|
379
|
+
</Table.Root>
|
|
380
|
+
</div>
|
|
381
|
+
<TablePagination count={mSessions.length} perPage={PAGE_SIZE} bind:page={mSessionsPage} />
|
|
382
|
+
</div>
|
|
383
|
+
{:else if mSection === "danger"}
|
|
384
|
+
<div class="border-destructive/50 flex flex-col gap-3 rounded-md border p-4">
|
|
385
|
+
<p class="text-muted-foreground text-sm">{fmt(i18n.t.users.deleteConfirm, { email: mUser?.email ?? "" })}</p>
|
|
386
|
+
{#if mDeleteArmed}
|
|
387
|
+
<div class="flex gap-2">
|
|
388
|
+
<Button variant="outline" onclick={() => (mDeleteArmed = false)}>{i18n.t.users.cancel}</Button>
|
|
389
|
+
<Button variant="destructive" disabled={busy} onclick={deleteUser}>{i18n.t.users.delete}</Button>
|
|
390
|
+
</div>
|
|
391
|
+
{:else}
|
|
392
|
+
<Button
|
|
393
|
+
variant="destructive"
|
|
394
|
+
class="w-fit"
|
|
395
|
+
disabled={mUser?.id === data.currentUserId}
|
|
396
|
+
onclick={() => (mDeleteArmed = true)}
|
|
397
|
+
>
|
|
398
|
+
{i18n.t.users.delete}
|
|
399
|
+
</Button>
|
|
400
|
+
{/if}
|
|
401
|
+
</div>
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
</Dialog.Content>
|
|
406
|
+
</Dialog.Root>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import LanguageSwitch from "$lib/components/language-switch.svelte";
|
|
3
|
+
let { children } = $props();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="bg-muted/30 relative flex min-h-svh items-center justify-center p-6">
|
|
7
|
+
<div class="absolute right-4 top-4">
|
|
8
|
+
<LanguageSwitch />
|
|
9
|
+
</div>
|
|
10
|
+
<div class="w-full max-w-sm">
|
|
11
|
+
{@render children()}
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "$lib/components/ui/button";
|
|
3
|
+
import { Input } from "$lib/components/ui/input";
|
|
4
|
+
import { Label } from "$lib/components/ui/label";
|
|
5
|
+
import * as Card from "$lib/components/ui/card";
|
|
6
|
+
import * as Alert from "$lib/components/ui/alert";
|
|
7
|
+
import { api } from "$lib/api";
|
|
8
|
+
import { getI18n } from "$lib/i18n";
|
|
9
|
+
|
|
10
|
+
const i18n = getI18n();
|
|
11
|
+
let email = $state("");
|
|
12
|
+
let sent = $state(false);
|
|
13
|
+
let error = $state<string | null>(null);
|
|
14
|
+
let loading = $state(false);
|
|
15
|
+
|
|
16
|
+
async function submit(event: SubmitEvent): Promise<void> {
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
loading = true;
|
|
19
|
+
error = null;
|
|
20
|
+
const { error: authError } = await api.auth.requestPasswordReset({ email, redirectTo: "/reset-password" });
|
|
21
|
+
loading = false;
|
|
22
|
+
if (authError) {
|
|
23
|
+
error = authError.message ?? i18n.t.auth.requestFailed;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
sent = true;
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<Card.Root>
|
|
31
|
+
<Card.Header>
|
|
32
|
+
<Card.Title>{i18n.t.auth.resetTitle}</Card.Title>
|
|
33
|
+
<Card.Description>{i18n.t.auth.resetDesc}</Card.Description>
|
|
34
|
+
</Card.Header>
|
|
35
|
+
<Card.Content>
|
|
36
|
+
{#if sent}
|
|
37
|
+
<Alert.Root><Alert.Description>{i18n.t.auth.resetSent}</Alert.Description></Alert.Root>
|
|
38
|
+
{:else}
|
|
39
|
+
<form class="flex flex-col gap-4" onsubmit={submit}>
|
|
40
|
+
{#if error}
|
|
41
|
+
<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
|
|
42
|
+
{/if}
|
|
43
|
+
<div class="flex flex-col gap-2">
|
|
44
|
+
<Label for="email">{i18n.t.auth.email}</Label>
|
|
45
|
+
<Input id="email" type="email" bind:value={email} required autocomplete="email" />
|
|
46
|
+
</div>
|
|
47
|
+
<Button type="submit" disabled={loading}>{loading ? i18n.t.auth.sending : i18n.t.auth.sendReset}</Button>
|
|
48
|
+
</form>
|
|
49
|
+
{/if}
|
|
50
|
+
</Card.Content>
|
|
51
|
+
<Card.Footer class="justify-center">
|
|
52
|
+
<a href="/login" class="text-muted-foreground text-sm hover:underline">{i18n.t.auth.backToSignIn}</a>
|
|
53
|
+
</Card.Footer>
|
|
54
|
+
</Card.Root>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import { page } from "$app/state";
|
|
4
|
+
import { Button } from "$lib/components/ui/button";
|
|
5
|
+
import { Input } from "$lib/components/ui/input";
|
|
6
|
+
import { Label } from "$lib/components/ui/label";
|
|
7
|
+
import * as Card from "$lib/components/ui/card";
|
|
8
|
+
import * as Alert from "$lib/components/ui/alert";
|
|
9
|
+
import { api } from "$lib/api";
|
|
10
|
+
import { getI18n } from "$lib/i18n";
|
|
11
|
+
|
|
12
|
+
const i18n = getI18n();
|
|
13
|
+
let email = $state("");
|
|
14
|
+
let password = $state("");
|
|
15
|
+
let error = $state<string | null>(null);
|
|
16
|
+
let loading = $state(false);
|
|
17
|
+
|
|
18
|
+
async function submit(event: SubmitEvent): Promise<void> {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
loading = true;
|
|
21
|
+
error = null;
|
|
22
|
+
const { error: authError } = await api.auth.signIn.email({ email, password });
|
|
23
|
+
loading = false;
|
|
24
|
+
if (authError) {
|
|
25
|
+
error = authError.message ?? i18n.t.auth.signInFailed;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await goto(page.url.searchParams.get("redirect") ?? "/admin", { invalidateAll: true });
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<Card.Root>
|
|
33
|
+
<Card.Header>
|
|
34
|
+
<Card.Title>{i18n.t.auth.signInTitle}</Card.Title>
|
|
35
|
+
<Card.Description>{i18n.t.auth.signInDesc}</Card.Description>
|
|
36
|
+
</Card.Header>
|
|
37
|
+
<Card.Content>
|
|
38
|
+
<form class="flex flex-col gap-4" onsubmit={submit}>
|
|
39
|
+
{#if error}
|
|
40
|
+
<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>
|
|
41
|
+
{/if}
|
|
42
|
+
<div class="flex flex-col gap-2">
|
|
43
|
+
<Label for="email">{i18n.t.auth.email}</Label>
|
|
44
|
+
<Input id="email" type="email" bind:value={email} required autocomplete="email" />
|
|
45
|
+
</div>
|
|
46
|
+
<div class="flex flex-col gap-2">
|
|
47
|
+
<div class="flex items-center justify-between">
|
|
48
|
+
<Label for="password">{i18n.t.auth.password}</Label>
|
|
49
|
+
<a href="/forgot-password" class="text-muted-foreground text-xs hover:underline">{i18n.t.auth.forgot}</a>
|
|
50
|
+
</div>
|
|
51
|
+
<Input id="password" type="password" bind:value={password} required autocomplete="current-password" />
|
|
52
|
+
</div>
|
|
53
|
+
<Button type="submit" disabled={loading}>{loading ? i18n.t.auth.signingIn : i18n.t.auth.signIn}</Button>
|
|
54
|
+
</form>
|
|
55
|
+
</Card.Content>
|
|
56
|
+
<Card.Footer class="justify-center">
|
|
57
|
+
<p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href="/signup" class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
|
|
58
|
+
</Card.Footer>
|
|
59
|
+
</Card.Root>
|