@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,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import * as Sidebar from "$lib/components/ui/sidebar";
|
|
4
|
+
import { Separator } from "$lib/components/ui/separator";
|
|
5
|
+
import { Button } from "$lib/components/ui/button";
|
|
6
|
+
import AppSidebar from "$lib/components/app-sidebar.svelte";
|
|
7
|
+
import ThemeToggle from "$lib/components/theme-toggle.svelte";
|
|
8
|
+
import LanguageSwitch from "$lib/components/language-switch.svelte";
|
|
9
|
+
import { Toaster } from "$lib/components/ui/sonner";
|
|
10
|
+
import { api } from "$lib/api";
|
|
11
|
+
import { getI18n } from "$lib/i18n";
|
|
12
|
+
import type { SessionUser } from "../../app.d.ts";
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
children,
|
|
16
|
+
data,
|
|
17
|
+
}: {
|
|
18
|
+
children: import("svelte").Snippet;
|
|
19
|
+
data: { user: SessionUser; impersonating: boolean; capabilities: { auditLog?: boolean } };
|
|
20
|
+
} = $props();
|
|
21
|
+
const i18n = getI18n();
|
|
22
|
+
|
|
23
|
+
async function stopImpersonating(): Promise<void> {
|
|
24
|
+
await api.auth.admin.stopImpersonating();
|
|
25
|
+
await goto("/admin", { invalidateAll: true });
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<Sidebar.Provider>
|
|
30
|
+
<AppSidebar user={data.user} capabilities={data.capabilities} />
|
|
31
|
+
<Sidebar.Inset>
|
|
32
|
+
{#if data.impersonating}
|
|
33
|
+
<div class="bg-primary text-primary-foreground flex items-center justify-between gap-2 px-4 py-2 text-sm">
|
|
34
|
+
<span>{i18n.t.users.impersonatingAs.replace("{email}", data.user.email)}</span>
|
|
35
|
+
<Button variant="secondary" size="sm" onclick={stopImpersonating}>{i18n.t.users.stopImpersonating}</Button>
|
|
36
|
+
</div>
|
|
37
|
+
{/if}
|
|
38
|
+
<header class="flex h-14 items-center gap-2 border-b px-4">
|
|
39
|
+
<Sidebar.Trigger />
|
|
40
|
+
<Separator orientation="vertical" class="mr-2 h-4" />
|
|
41
|
+
<div class="flex-1"></div>
|
|
42
|
+
<LanguageSwitch />
|
|
43
|
+
<ThemeToggle />
|
|
44
|
+
</header>
|
|
45
|
+
<main class="flex-1 overflow-auto p-6">
|
|
46
|
+
{@render children()}
|
|
47
|
+
</main>
|
|
48
|
+
</Sidebar.Inset>
|
|
49
|
+
</Sidebar.Provider>
|
|
50
|
+
<Toaster />
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as Card from "$lib/components/ui/card";
|
|
3
|
+
import { getI18n, fmt } from "$lib/i18n";
|
|
4
|
+
import type { SessionUser } from "../../../app.d.ts";
|
|
5
|
+
|
|
6
|
+
let { data }: { data: { user: SessionUser } } = $props();
|
|
7
|
+
const i18n = getI18n();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div class="flex flex-col gap-6">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 class="text-2xl font-semibold">{i18n.t.dashboard.title}</h1>
|
|
13
|
+
<p class="text-muted-foreground text-sm">{fmt(i18n.t.dashboard.welcome, { name: data.user.name })}</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
16
|
+
<Card.Root>
|
|
17
|
+
<Card.Header><Card.Title>{i18n.t.dashboard.yourRole}</Card.Title></Card.Header>
|
|
18
|
+
<Card.Content class="text-2xl font-semibold capitalize">{data.user.role ?? "user"}</Card.Content>
|
|
19
|
+
</Card.Root>
|
|
20
|
+
<Card.Root>
|
|
21
|
+
<Card.Header><Card.Title>{i18n.t.dashboard.signedInAs}</Card.Title></Card.Header>
|
|
22
|
+
<Card.Content class="text-muted-foreground truncate text-sm">{data.user.email}</Card.Content>
|
|
23
|
+
</Card.Root>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,374 @@
|
|
|
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 * as Card from "$lib/components/ui/card";
|
|
8
|
+
import * as Table from "$lib/components/ui/table";
|
|
9
|
+
import * as Dialog from "$lib/components/ui/dialog";
|
|
10
|
+
import TablePagination from "$lib/components/table-pagination.svelte";
|
|
11
|
+
import { toast } from "svelte-sonner";
|
|
12
|
+
import { api } from "$lib/api";
|
|
13
|
+
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
14
|
+
import { untrack } from "svelte";
|
|
15
|
+
import type { SessionUser } from "../../../../app.d.ts";
|
|
16
|
+
|
|
17
|
+
type Capabilities = { twoFactor: boolean; providers: string[]; deleteAccount: boolean; auditLog: boolean };
|
|
18
|
+
let { data }: { data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities } } = $props();
|
|
19
|
+
const i18n = getI18n();
|
|
20
|
+
|
|
21
|
+
// Optional features (resolved by the layout) so we only show enabled sections.
|
|
22
|
+
const caps = $derived(data.capabilities);
|
|
23
|
+
|
|
24
|
+
type SectionKey = "profile" | "security" | "connected" | "sessions" | "danger";
|
|
25
|
+
let section = $state<SectionKey>("profile");
|
|
26
|
+
const navItems = $derived<SectionKey[]>([
|
|
27
|
+
"profile",
|
|
28
|
+
"security",
|
|
29
|
+
...(caps.providers.length ? (["connected"] as const) : []),
|
|
30
|
+
"sessions",
|
|
31
|
+
...(caps.deleteAccount ? (["danger"] as const) : []),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// Profile — seed the editable field from the initial user value.
|
|
35
|
+
let name = $state(untrack(() => data.user.name));
|
|
36
|
+
let savingProfile = $state(false);
|
|
37
|
+
const nameChanged = $derived(name.trim() !== "" && name !== data.user.name);
|
|
38
|
+
async function saveProfile(event: SubmitEvent): Promise<void> {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
savingProfile = true;
|
|
41
|
+
const { error } = await api.auth.updateUser({ name });
|
|
42
|
+
savingProfile = false;
|
|
43
|
+
if (error) toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
44
|
+
else toast.success(i18n.t.account.saved);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Security — change password
|
|
48
|
+
let currentPassword = $state("");
|
|
49
|
+
let newPassword = $state("");
|
|
50
|
+
let changing = $state(false);
|
|
51
|
+
async function changePassword(event: SubmitEvent): Promise<void> {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
changing = true;
|
|
54
|
+
const { error } = await api.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
|
|
55
|
+
changing = false;
|
|
56
|
+
if (error) toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
57
|
+
else {
|
|
58
|
+
toast.success(i18n.t.account.changed);
|
|
59
|
+
currentPassword = "";
|
|
60
|
+
newPassword = "";
|
|
61
|
+
await loadSessions();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Security — two-factor
|
|
66
|
+
let twoFaOn = $state<boolean>(untrack(() => data.user.twoFactorEnabled ?? false));
|
|
67
|
+
let twoFaPassword = $state("");
|
|
68
|
+
let twoFaCode = $state("");
|
|
69
|
+
let twoFaBusy = $state(false);
|
|
70
|
+
let setup = $state<{ totpURI: string; backupCodes: string[] } | null>(null);
|
|
71
|
+
async function enable2fa(): Promise<void> {
|
|
72
|
+
twoFaBusy = true;
|
|
73
|
+
const { data: res, error } = await api.auth.twoFactor.enable({ password: twoFaPassword });
|
|
74
|
+
twoFaBusy = false;
|
|
75
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
76
|
+
setup = { totpURI: res?.totpURI ?? "", backupCodes: (res?.backupCodes ?? []) as string[] };
|
|
77
|
+
}
|
|
78
|
+
async function verify2fa(): Promise<void> {
|
|
79
|
+
twoFaBusy = true;
|
|
80
|
+
const { error } = await api.auth.twoFactor.verifyTotp({ code: twoFaCode });
|
|
81
|
+
twoFaBusy = false;
|
|
82
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
83
|
+
twoFaOn = true;
|
|
84
|
+
setup = null;
|
|
85
|
+
twoFaPassword = "";
|
|
86
|
+
twoFaCode = "";
|
|
87
|
+
toast.success(i18n.t.account.twoFactorEnabled);
|
|
88
|
+
}
|
|
89
|
+
async function disable2fa(): Promise<void> {
|
|
90
|
+
twoFaBusy = true;
|
|
91
|
+
const { error } = await api.auth.twoFactor.disable({ password: twoFaPassword });
|
|
92
|
+
twoFaBusy = false;
|
|
93
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
94
|
+
twoFaOn = false;
|
|
95
|
+
twoFaPassword = "";
|
|
96
|
+
toast.success(i18n.t.account.twoFactorDisabled);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Connected accounts
|
|
100
|
+
type Account = { id: string; providerId: string; accountId: string };
|
|
101
|
+
let accounts = $state<Account[]>([]);
|
|
102
|
+
async function loadAccounts(): Promise<void> {
|
|
103
|
+
const { data: res } = await api.auth.listAccounts();
|
|
104
|
+
accounts = (res ?? []) as Account[];
|
|
105
|
+
}
|
|
106
|
+
const linkedProviders = $derived(new Set(accounts.map((a) => a.providerId)));
|
|
107
|
+
async function connect(provider: string): Promise<void> {
|
|
108
|
+
const { data: res, error } = await api.auth.linkSocial({ provider, callbackURL: "/admin/account" });
|
|
109
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
110
|
+
if (res?.url) window.location.href = res.url;
|
|
111
|
+
}
|
|
112
|
+
async function disconnect(provider: string): Promise<void> {
|
|
113
|
+
const account = accounts.find((a) => a.providerId === provider);
|
|
114
|
+
if (!account) return;
|
|
115
|
+
const { error } = await api.auth.unlinkAccount({ providerId: provider, accountId: account.accountId });
|
|
116
|
+
if (error) toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
117
|
+
else {
|
|
118
|
+
toast.success(i18n.t.account.disconnected);
|
|
119
|
+
await loadAccounts();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Danger — delete account
|
|
124
|
+
let deleteOpen = $state(false);
|
|
125
|
+
let deletePassword = $state("");
|
|
126
|
+
let deleteBusy = $state(false);
|
|
127
|
+
async function deleteAccount(): Promise<void> {
|
|
128
|
+
deleteBusy = true;
|
|
129
|
+
const { error } = await api.auth.deleteUser({ password: deletePassword });
|
|
130
|
+
deleteBusy = false;
|
|
131
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
132
|
+
toast.success(i18n.t.account.accountDeleted);
|
|
133
|
+
await goto("/login", { invalidateAll: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Sessions — this user's own devices
|
|
137
|
+
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
138
|
+
const PAGE_SIZE = 5;
|
|
139
|
+
let sessions = $state<Session[]>([]);
|
|
140
|
+
let sessionsPage = $state(1);
|
|
141
|
+
let busy = $state(false);
|
|
142
|
+
const pagedSessions = $derived(sessions.slice((sessionsPage - 1) * PAGE_SIZE, sessionsPage * PAGE_SIZE));
|
|
143
|
+
async function loadSessions(): Promise<void> {
|
|
144
|
+
const { data: res, error } = await api.auth.listSessions();
|
|
145
|
+
if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
|
|
146
|
+
sessions = (res ?? []) as Session[];
|
|
147
|
+
}
|
|
148
|
+
async function revoke(token: string): Promise<void> {
|
|
149
|
+
busy = true;
|
|
150
|
+
const { error } = await api.auth.revokeSession({ token });
|
|
151
|
+
busy = false;
|
|
152
|
+
if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
|
|
153
|
+
else {
|
|
154
|
+
toast.success(i18n.t.sessions.revoked);
|
|
155
|
+
await loadSessions();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function signOutOthers(): Promise<void> {
|
|
159
|
+
busy = true;
|
|
160
|
+
const { error } = await api.auth.revokeOtherSessions();
|
|
161
|
+
busy = false;
|
|
162
|
+
if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
|
|
163
|
+
else {
|
|
164
|
+
toast.success(i18n.t.sessions.othersRevoked);
|
|
165
|
+
await loadSessions();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
$effect(() => {
|
|
170
|
+
void loadSessions();
|
|
171
|
+
void loadAccounts();
|
|
172
|
+
});
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<div class="flex flex-col gap-6">
|
|
176
|
+
<h1 class="text-2xl font-semibold">{i18n.t.account.title}</h1>
|
|
177
|
+
|
|
178
|
+
<div class="flex flex-col gap-6 lg:flex-row">
|
|
179
|
+
<nav class="flex shrink-0 flex-wrap gap-1 lg:w-48 lg:flex-col">
|
|
180
|
+
{#each navItems as key (key)}
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onclick={() => (section = key)}
|
|
184
|
+
aria-current={section === key ? "page" : undefined}
|
|
185
|
+
class="hover:bg-muted rounded-md px-3 py-2 text-left text-sm font-medium transition-colors {section === key ? 'bg-muted' : 'text-muted-foreground'}"
|
|
186
|
+
>
|
|
187
|
+
{i18n.t.account.nav[key]}
|
|
188
|
+
</button>
|
|
189
|
+
{/each}
|
|
190
|
+
</nav>
|
|
191
|
+
|
|
192
|
+
<div class="min-w-0 flex-1">
|
|
193
|
+
{#if section === "profile"}
|
|
194
|
+
<Card.Root>
|
|
195
|
+
<Card.Header><Card.Title>{i18n.t.account.profile}</Card.Title></Card.Header>
|
|
196
|
+
<Card.Content>
|
|
197
|
+
<form class="flex max-w-md flex-col gap-4" onsubmit={saveProfile}>
|
|
198
|
+
<div class="flex flex-col gap-2">
|
|
199
|
+
<Label for="name">{i18n.t.account.name}</Label>
|
|
200
|
+
<Input id="name" bind:value={name} required />
|
|
201
|
+
</div>
|
|
202
|
+
<div class="flex flex-col gap-2">
|
|
203
|
+
<Label for="email">{i18n.t.account.email}</Label>
|
|
204
|
+
<div class="flex items-center gap-2">
|
|
205
|
+
<Input id="email" value={data.user.email} readonly class="text-muted-foreground" />
|
|
206
|
+
<Badge variant={data.user.emailVerified ? "outline" : "secondary"}>
|
|
207
|
+
{data.user.emailVerified ? i18n.t.account.verified : i18n.t.account.unverified}
|
|
208
|
+
</Badge>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="flex flex-col gap-2">
|
|
212
|
+
<Label>{i18n.t.account.role}</Label>
|
|
213
|
+
<div class="text-muted-foreground text-sm capitalize">{data.user.role ?? "user"}</div>
|
|
214
|
+
</div>
|
|
215
|
+
<Button type="submit" class="w-fit" disabled={savingProfile || !nameChanged}>{i18n.t.account.save}</Button>
|
|
216
|
+
</form>
|
|
217
|
+
</Card.Content>
|
|
218
|
+
</Card.Root>
|
|
219
|
+
{:else if section === "security"}
|
|
220
|
+
<div class="flex flex-col gap-6">
|
|
221
|
+
<Card.Root>
|
|
222
|
+
<Card.Header><Card.Title>{i18n.t.account.changePassword}</Card.Title></Card.Header>
|
|
223
|
+
<Card.Content>
|
|
224
|
+
<form class="flex max-w-md flex-col gap-4" onsubmit={changePassword}>
|
|
225
|
+
<div class="flex flex-col gap-2">
|
|
226
|
+
<Label for="current">{i18n.t.account.currentPassword}</Label>
|
|
227
|
+
<Input id="current" type="password" bind:value={currentPassword} required autocomplete="current-password" />
|
|
228
|
+
</div>
|
|
229
|
+
<div class="flex flex-col gap-2">
|
|
230
|
+
<Label for="new">{i18n.t.account.newPassword}</Label>
|
|
231
|
+
<Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
|
232
|
+
</div>
|
|
233
|
+
<Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
|
|
234
|
+
</form>
|
|
235
|
+
</Card.Content>
|
|
236
|
+
</Card.Root>
|
|
237
|
+
|
|
238
|
+
{#if caps.twoFactor}
|
|
239
|
+
<Card.Root>
|
|
240
|
+
<Card.Header class="flex flex-row items-center justify-between gap-2">
|
|
241
|
+
<div>
|
|
242
|
+
<Card.Title>{i18n.t.account.twoFactorTitle}</Card.Title>
|
|
243
|
+
<p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.twoFactorDesc}</p>
|
|
244
|
+
</div>
|
|
245
|
+
<Badge variant={twoFaOn ? "default" : "secondary"}>{twoFaOn ? i18n.t.account.twoFactorOn : i18n.t.account.twoFactorOff}</Badge>
|
|
246
|
+
</Card.Header>
|
|
247
|
+
<Card.Content class="flex max-w-md flex-col gap-4">
|
|
248
|
+
{#if twoFaOn}
|
|
249
|
+
<div class="flex flex-col gap-2">
|
|
250
|
+
<Label for="tf-off-pw">{i18n.t.account.password}</Label>
|
|
251
|
+
<Input id="tf-off-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
|
|
252
|
+
</div>
|
|
253
|
+
<Button variant="destructive" class="w-fit" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
|
|
254
|
+
{:else if setup}
|
|
255
|
+
<p class="text-muted-foreground text-sm">{i18n.t.account.scanHint}</p>
|
|
256
|
+
<div class="flex flex-col gap-1">
|
|
257
|
+
<Label>{i18n.t.account.setupKey}</Label>
|
|
258
|
+
<code class="bg-muted overflow-x-auto rounded px-2 py-1 text-xs">{setup.totpURI}</code>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="flex flex-col gap-1">
|
|
261
|
+
<Label>{i18n.t.account.backupCodes}</Label>
|
|
262
|
+
<div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs">
|
|
263
|
+
{#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="flex flex-col gap-2">
|
|
267
|
+
<Label for="tf-code">{i18n.t.account.code}</Label>
|
|
268
|
+
<Input id="tf-code" bind:value={twoFaCode} inputmode="numeric" autocomplete="one-time-code" />
|
|
269
|
+
</div>
|
|
270
|
+
<Button class="w-fit" disabled={twoFaBusy || !twoFaCode} onclick={verify2fa}>{i18n.t.account.verify}</Button>
|
|
271
|
+
{:else}
|
|
272
|
+
<div class="flex flex-col gap-2">
|
|
273
|
+
<Label for="tf-on-pw">{i18n.t.account.password}</Label>
|
|
274
|
+
<Input id="tf-on-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
|
|
275
|
+
</div>
|
|
276
|
+
<Button class="w-fit" disabled={twoFaBusy || !twoFaPassword} onclick={enable2fa}>{i18n.t.account.enable}</Button>
|
|
277
|
+
{/if}
|
|
278
|
+
</Card.Content>
|
|
279
|
+
</Card.Root>
|
|
280
|
+
{/if}
|
|
281
|
+
</div>
|
|
282
|
+
{:else if section === "connected"}
|
|
283
|
+
<Card.Root>
|
|
284
|
+
<Card.Header>
|
|
285
|
+
<Card.Title>{i18n.t.account.connectedTitle}</Card.Title>
|
|
286
|
+
<p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.connectedDesc}</p>
|
|
287
|
+
</Card.Header>
|
|
288
|
+
<Card.Content class="flex flex-col gap-3">
|
|
289
|
+
{#each caps.providers as provider (provider)}
|
|
290
|
+
{@const isLinked = linkedProviders.has(provider)}
|
|
291
|
+
<div class="flex items-center justify-between gap-4 rounded-md border p-3">
|
|
292
|
+
<div class="flex items-center gap-2">
|
|
293
|
+
<span class="font-medium capitalize">{provider}</span>
|
|
294
|
+
<Badge variant={isLinked ? "outline" : "secondary"}>{isLinked ? i18n.t.account.connected : i18n.t.account.notConnected}</Badge>
|
|
295
|
+
</div>
|
|
296
|
+
{#if isLinked}
|
|
297
|
+
<Button variant="outline" size="sm" onclick={() => disconnect(provider)}>{i18n.t.account.disconnect}</Button>
|
|
298
|
+
{:else}
|
|
299
|
+
<Button size="sm" onclick={() => connect(provider)}>{i18n.t.account.connect}</Button>
|
|
300
|
+
{/if}
|
|
301
|
+
</div>
|
|
302
|
+
{/each}
|
|
303
|
+
</Card.Content>
|
|
304
|
+
</Card.Root>
|
|
305
|
+
{:else if section === "sessions"}
|
|
306
|
+
<Card.Root>
|
|
307
|
+
<Card.Header class="flex flex-row items-center justify-between gap-2">
|
|
308
|
+
<Card.Title>{i18n.t.sessions.title}</Card.Title>
|
|
309
|
+
<Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
|
|
310
|
+
</Card.Header>
|
|
311
|
+
<Card.Content>
|
|
312
|
+
<div class="rounded-md border">
|
|
313
|
+
<Table.Root>
|
|
314
|
+
<Table.Header>
|
|
315
|
+
<Table.Row>
|
|
316
|
+
<Table.Head>{i18n.t.sessions.device}</Table.Head>
|
|
317
|
+
<Table.Head>{i18n.t.sessions.ip}</Table.Head>
|
|
318
|
+
<Table.Head>{i18n.t.sessions.since}</Table.Head>
|
|
319
|
+
<Table.Head>{i18n.t.sessions.status}</Table.Head>
|
|
320
|
+
<Table.Head class="w-10"></Table.Head>
|
|
321
|
+
</Table.Row>
|
|
322
|
+
</Table.Header>
|
|
323
|
+
<Table.Body>
|
|
324
|
+
{#each pagedSessions as s (s.id)}
|
|
325
|
+
{@const isCurrent = s.id === data.currentSessionId}
|
|
326
|
+
<Table.Row>
|
|
327
|
+
<Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
|
|
328
|
+
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
329
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
330
|
+
<Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
|
|
331
|
+
<Table.Cell>
|
|
332
|
+
<Button variant="ghost" size="sm" disabled={busy || isCurrent} onclick={() => revoke(s.token)}>{i18n.t.sessions.revoke}</Button>
|
|
333
|
+
</Table.Cell>
|
|
334
|
+
</Table.Row>
|
|
335
|
+
{:else}
|
|
336
|
+
<Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{i18n.t.sessions.empty}</Table.Cell></Table.Row>
|
|
337
|
+
{/each}
|
|
338
|
+
</Table.Body>
|
|
339
|
+
</Table.Root>
|
|
340
|
+
</div>
|
|
341
|
+
<div class="mt-4">
|
|
342
|
+
<TablePagination count={sessions.length} perPage={PAGE_SIZE} bind:page={sessionsPage} label={fmt(i18n.t.sessions.total, { count: sessions.length })} />
|
|
343
|
+
</div>
|
|
344
|
+
</Card.Content>
|
|
345
|
+
</Card.Root>
|
|
346
|
+
{:else if section === "danger"}
|
|
347
|
+
<Card.Root class="border-destructive/50">
|
|
348
|
+
<Card.Header>
|
|
349
|
+
<Card.Title class="text-destructive">{i18n.t.account.dangerTitle}</Card.Title>
|
|
350
|
+
<p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.dangerDesc}</p>
|
|
351
|
+
</Card.Header>
|
|
352
|
+
<Card.Content>
|
|
353
|
+
<Button variant="destructive" onclick={() => { deletePassword = ""; deleteOpen = true; }}>{i18n.t.account.deleteAccount}</Button>
|
|
354
|
+
</Card.Content>
|
|
355
|
+
</Card.Root>
|
|
356
|
+
{/if}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<Dialog.Root bind:open={deleteOpen}>
|
|
362
|
+
<Dialog.Content>
|
|
363
|
+
<Dialog.Header><Dialog.Title>{i18n.t.account.deleteAccount}</Dialog.Title></Dialog.Header>
|
|
364
|
+
<p class="text-muted-foreground text-sm">{i18n.t.account.deleteAccountConfirm}</p>
|
|
365
|
+
<div class="flex flex-col gap-2">
|
|
366
|
+
<Label for="del-pw">{i18n.t.account.password}</Label>
|
|
367
|
+
<Input id="del-pw" type="password" bind:value={deletePassword} autocomplete="current-password" />
|
|
368
|
+
</div>
|
|
369
|
+
<Dialog.Footer>
|
|
370
|
+
<Button variant="outline" onclick={() => (deleteOpen = false)}>{i18n.t.users.cancel}</Button>
|
|
371
|
+
<Button variant="destructive" disabled={deleteBusy || !deletePassword} onclick={deleteAccount}>{i18n.t.account.deleteAccount}</Button>
|
|
372
|
+
</Dialog.Footer>
|
|
373
|
+
</Dialog.Content>
|
|
374
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from "$lib/components/ui/badge";
|
|
3
|
+
import * as Table from "$lib/components/ui/table";
|
|
4
|
+
import TablePagination from "$lib/components/table-pagination.svelte";
|
|
5
|
+
import { toast } from "svelte-sonner";
|
|
6
|
+
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
7
|
+
|
|
8
|
+
const i18n = getI18n();
|
|
9
|
+
type Entry = {
|
|
10
|
+
id: string;
|
|
11
|
+
action: string;
|
|
12
|
+
actorId: string | null;
|
|
13
|
+
actorName: string | null;
|
|
14
|
+
actorEmail: string | null;
|
|
15
|
+
targetLabel: string | null;
|
|
16
|
+
ip: string | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const PAGE_SIZE = 10;
|
|
21
|
+
let entries = $state<Entry[]>([]);
|
|
22
|
+
let page = $state(1);
|
|
23
|
+
const paged = $derived(entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
|
|
24
|
+
|
|
25
|
+
async function load(): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch("/api/audit-logs");
|
|
28
|
+
if (!res.ok) throw new Error();
|
|
29
|
+
entries = (await res.json()) as Entry[];
|
|
30
|
+
} catch {
|
|
31
|
+
toast.error(i18n.t.audit.loadFailed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$effect(() => {
|
|
36
|
+
void load();
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="flex flex-col gap-4">
|
|
41
|
+
<h1 class="text-2xl font-semibold">{i18n.t.audit.title}</h1>
|
|
42
|
+
|
|
43
|
+
<div class="rounded-md border">
|
|
44
|
+
<Table.Root>
|
|
45
|
+
<Table.Header>
|
|
46
|
+
<Table.Row>
|
|
47
|
+
<Table.Head>{i18n.t.audit.when}</Table.Head>
|
|
48
|
+
<Table.Head>{i18n.t.audit.actor}</Table.Head>
|
|
49
|
+
<Table.Head>{i18n.t.audit.action}</Table.Head>
|
|
50
|
+
<Table.Head>{i18n.t.audit.target}</Table.Head>
|
|
51
|
+
<Table.Head>{i18n.t.audit.ip}</Table.Head>
|
|
52
|
+
</Table.Row>
|
|
53
|
+
</Table.Header>
|
|
54
|
+
<Table.Body>
|
|
55
|
+
{#each paged as e (e.id)}
|
|
56
|
+
<Table.Row>
|
|
57
|
+
<Table.Cell class="text-muted-foreground whitespace-nowrap">{formatDateTime(e.createdAt)}</Table.Cell>
|
|
58
|
+
<Table.Cell>
|
|
59
|
+
{#if e.actorName || e.actorEmail}
|
|
60
|
+
<div class="font-medium">{e.actorName ?? e.actorEmail}</div>
|
|
61
|
+
{#if e.actorName && e.actorEmail}<div class="text-muted-foreground text-xs">{e.actorEmail}</div>{/if}
|
|
62
|
+
{:else}
|
|
63
|
+
<span class="text-muted-foreground">—</span>
|
|
64
|
+
{/if}
|
|
65
|
+
</Table.Cell>
|
|
66
|
+
<Table.Cell><Badge variant="secondary" class="font-mono text-xs">{e.action}</Badge></Table.Cell>
|
|
67
|
+
<Table.Cell class="max-w-xs truncate">{e.targetLabel ?? "—"}</Table.Cell>
|
|
68
|
+
<Table.Cell class="text-muted-foreground">{e.ip ?? "—"}</Table.Cell>
|
|
69
|
+
</Table.Row>
|
|
70
|
+
{:else}
|
|
71
|
+
<Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{i18n.t.audit.empty}</Table.Cell></Table.Row>
|
|
72
|
+
{/each}
|
|
73
|
+
</Table.Body>
|
|
74
|
+
</Table.Root>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<TablePagination count={entries.length} perPage={PAGE_SIZE} bind:page label={fmt(i18n.t.audit.total, { count: entries.length })} />
|
|
78
|
+
</div>
|