@podosoft/podokit 0.4.0 → 0.5.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/README.md +2 -2
- package/dist/add.d.ts +2 -0
- package/dist/add.js +5 -1
- package/dist/assemble.d.ts +14 -0
- package/dist/assemble.js +60 -0
- package/dist/create.d.ts +2 -0
- package/dist/create.js +7 -0
- package/dist/eject.d.ts +11 -0
- package/dist/eject.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -1
- package/dist/inspect.d.ts +43 -0
- package/dist/inspect.js +82 -0
- package/dist/lockfile.d.ts +80 -0
- package/dist/lockfile.js +193 -0
- package/dist/templates/base/dot-env.example +11 -6
- package/dist/templates/fullstack-nest-svelte/README.md +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
- package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
- package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
- package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
- package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
- package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
- package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
- package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
- package/dist/templates/modules/auth/module.manifest.json +54 -20
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
- package/dist/templates/modules/bullmq/module.manifest.json +4 -4
- package/dist/templates/modules/file-upload/module.manifest.json +3 -3
- package/dist/templates/modules/job-progress/module.manifest.json +9 -9
- package/dist/templates/modules/logging/module.manifest.json +3 -3
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
- package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
- package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
- package/dist/templates/modules/redis/module.manifest.json +4 -4
- package/dist/templates/modules/sse/module.manifest.json +4 -4
- package/dist/templates/todo/README.md +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
- package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/todo/apps/api/src/main.ts +1 -1
- package/dist/templates/todo/apps/web/package.json +2 -2
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/todo/dot-env.example +10 -5
- package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
- package/dist/templates/todo/tests/README.md +1 -1
- package/dist/templates/todo/tests/package.json +2 -1
- package/dist/templates/todo/tests/playwright.config.ts +1 -1
- package/dist/update.d.ts +49 -0
- package/dist/update.js +158 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from "$app/navigation";
|
|
3
|
+
import QRCode from "qrcode";
|
|
3
4
|
import { Button } from "$lib/components/ui/button";
|
|
4
5
|
import { Input } from "$lib/components/ui/input";
|
|
5
6
|
import { Label } from "$lib/components/ui/label";
|
|
@@ -7,43 +8,99 @@
|
|
|
7
8
|
import * as Card from "$lib/components/ui/card";
|
|
8
9
|
import * as Table from "$lib/components/ui/table";
|
|
9
10
|
import * as Dialog from "$lib/components/ui/dialog";
|
|
10
|
-
import
|
|
11
|
+
import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
|
|
11
12
|
import { toast } from "svelte-sonner";
|
|
12
13
|
import { api } from "$lib/api";
|
|
13
14
|
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
14
15
|
import { untrack } from "svelte";
|
|
15
16
|
import type { SessionUser } from "../../../../app.d.ts";
|
|
17
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
16
18
|
|
|
17
|
-
type Capabilities = { twoFactor: boolean; providers: string[]; deleteAccount: boolean; auditLog: boolean };
|
|
18
19
|
let { data }: { data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities } } = $props();
|
|
19
20
|
const i18n = getI18n();
|
|
20
21
|
|
|
21
22
|
// Optional features (resolved by the layout) so we only show enabled sections.
|
|
22
23
|
const caps = $derived(data.capabilities);
|
|
23
24
|
|
|
24
|
-
type SectionKey = "profile" | "security" | "connected" | "sessions" | "danger";
|
|
25
|
+
type SectionKey = "profile" | "security" | "connected" | "sessions" | "apiKeys" | "danger";
|
|
25
26
|
let section = $state<SectionKey>("profile");
|
|
26
27
|
const navItems = $derived<SectionKey[]>([
|
|
27
28
|
"profile",
|
|
28
29
|
"security",
|
|
29
30
|
...(caps.providers.length ? (["connected"] as const) : []),
|
|
30
31
|
"sessions",
|
|
32
|
+
...(caps.apiKey ? (["apiKeys"] as const) : []),
|
|
31
33
|
...(caps.deleteAccount ? (["danger"] as const) : []),
|
|
32
34
|
]);
|
|
33
35
|
|
|
34
|
-
// Profile — seed the editable
|
|
36
|
+
// Profile — seed the editable fields from the initial user value.
|
|
35
37
|
let name = $state(untrack(() => data.user.name));
|
|
38
|
+
let username = $state(untrack(() => data.user.username ?? ""));
|
|
36
39
|
let savingProfile = $state(false);
|
|
37
|
-
const nameChanged = $derived(
|
|
40
|
+
const nameChanged = $derived(
|
|
41
|
+
(name.trim() !== "" && name !== data.user.name) || (caps.username && username !== (data.user.username ?? "")),
|
|
42
|
+
);
|
|
38
43
|
async function saveProfile(event: SubmitEvent): Promise<void> {
|
|
39
44
|
event.preventDefault();
|
|
40
45
|
savingProfile = true;
|
|
41
|
-
|
|
46
|
+
// Only send username when it's set and actually changed — updateUser rejects
|
|
47
|
+
// an empty username, which would block name-only edits for users without one.
|
|
48
|
+
const usernameChanged = caps.username && username.trim() !== "" && username !== (data.user.username ?? "");
|
|
49
|
+
const { error } = await api.auth.updateUser({ name, ...(usernameChanged ? { username: username.trim() } : {}) });
|
|
42
50
|
savingProfile = false;
|
|
43
51
|
if (error) toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
44
52
|
else toast.success(i18n.t.account.saved);
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
// Phone number — register + verify with an SMS code (dev delivery is a stub).
|
|
56
|
+
let phone = $state(untrack(() => data.user.phoneNumber ?? ""));
|
|
57
|
+
let phoneCode = $state("");
|
|
58
|
+
let phoneOtpSent = $state(false);
|
|
59
|
+
let phoneBusy = $state(false);
|
|
60
|
+
async function sendPhoneOtp(): Promise<void> {
|
|
61
|
+
phoneBusy = true;
|
|
62
|
+
const { error } = await api.auth.phoneNumber.sendOtp({ phoneNumber: phone });
|
|
63
|
+
phoneBusy = false;
|
|
64
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
65
|
+
phoneOtpSent = true;
|
|
66
|
+
toast.success(i18n.t.account.phoneCodeSent);
|
|
67
|
+
}
|
|
68
|
+
async function verifyPhone(): Promise<void> {
|
|
69
|
+
phoneBusy = true;
|
|
70
|
+
// updatePhoneNumber: save the verified number on the current user (without it,
|
|
71
|
+
// verify follows the sign-in path and never associates the phone).
|
|
72
|
+
const { error } = await api.auth.phoneNumber.verify({ phoneNumber: phone, code: phoneCode, updatePhoneNumber: true });
|
|
73
|
+
phoneBusy = false;
|
|
74
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
75
|
+
phoneOtpSent = false;
|
|
76
|
+
phoneCode = "";
|
|
77
|
+
toast.success(i18n.t.account.phoneVerified);
|
|
78
|
+
await goto("/admin/account", { invalidateAll: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Email change — when verification is on, better-auth emails an approval link
|
|
82
|
+
// to the current address; otherwise the address switches immediately.
|
|
83
|
+
let newEmail = $state(untrack(() => data.user.email));
|
|
84
|
+
let changingEmail = $state(false);
|
|
85
|
+
const emailChanged = $derived(newEmail.trim() !== "" && newEmail !== data.user.email);
|
|
86
|
+
async function changeEmail(): Promise<void> {
|
|
87
|
+
changingEmail = true;
|
|
88
|
+
const { error } = await api.auth.changeEmail({ newEmail, callbackURL: `${location.origin}/admin` });
|
|
89
|
+
changingEmail = false;
|
|
90
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
91
|
+
toast.success(caps.emailVerification ? i18n.t.account.emailChangePending : i18n.t.account.emailChanged);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Email verification — resend the confirmation link to the current address.
|
|
95
|
+
let verifying = $state(false);
|
|
96
|
+
async function resendVerification(): Promise<void> {
|
|
97
|
+
verifying = true;
|
|
98
|
+
const { error } = await api.auth.sendVerificationEmail({ email: data.user.email, callbackURL: `${location.origin}/admin` });
|
|
99
|
+
verifying = false;
|
|
100
|
+
if (error) toast.error(error.message ?? i18n.t.auth.requestFailed);
|
|
101
|
+
else toast.success(i18n.t.auth.verifySent);
|
|
102
|
+
}
|
|
103
|
+
|
|
47
104
|
// Security — change password
|
|
48
105
|
let currentPassword = $state("");
|
|
49
106
|
let newPassword = $state("");
|
|
@@ -68,6 +125,18 @@
|
|
|
68
125
|
let twoFaCode = $state("");
|
|
69
126
|
let twoFaBusy = $state(false);
|
|
70
127
|
let setup = $state<{ totpURI: string; backupCodes: string[] } | null>(null);
|
|
128
|
+
// Render the otpauth URI as a scannable QR image (data URL) during setup.
|
|
129
|
+
let qrDataUrl = $state("");
|
|
130
|
+
$effect(() => {
|
|
131
|
+
const uri = setup?.totpURI;
|
|
132
|
+
if (!uri) {
|
|
133
|
+
qrDataUrl = "";
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
QRCode.toDataURL(uri, { margin: 1, width: 176 })
|
|
137
|
+
.then((url) => (qrDataUrl = url))
|
|
138
|
+
.catch(() => (qrDataUrl = ""));
|
|
139
|
+
});
|
|
71
140
|
async function enable2fa(): Promise<void> {
|
|
72
141
|
twoFaBusy = true;
|
|
73
142
|
const { data: res, error } = await api.auth.twoFactor.enable({ password: twoFaPassword });
|
|
@@ -96,6 +165,43 @@
|
|
|
96
165
|
toast.success(i18n.t.account.twoFactorDisabled);
|
|
97
166
|
}
|
|
98
167
|
|
|
168
|
+
// Passkeys (WebAuthn) — passwordless credentials bound to this device/browser.
|
|
169
|
+
type Passkey = { id: string; name: string | null; createdAt: string | Date };
|
|
170
|
+
let passkeys = $state<Passkey[]>([]);
|
|
171
|
+
let passkeysPage = $state(1);
|
|
172
|
+
let passkeysSort = $state<SortState | null>(null);
|
|
173
|
+
const passkeysColumns: DataTableColumn<Passkey>[] = [
|
|
174
|
+
{ key: "name", label: i18n.t.passkeys.name, sortable: true, value: (p) => p.name ?? "" },
|
|
175
|
+
{ key: "createdAt", label: i18n.t.passkeys.created, sortable: true, value: (p) => new Date(p.createdAt).getTime() },
|
|
176
|
+
{ key: "actions", label: "", class: "w-10" },
|
|
177
|
+
];
|
|
178
|
+
let newPasskeyName = $state("");
|
|
179
|
+
let passkeyBusy = $state(false);
|
|
180
|
+
async function loadPasskeys(): Promise<void> {
|
|
181
|
+
if (!caps.passkey) return;
|
|
182
|
+
const { data: res } = await api.auth.passkey.listUserPasskeys();
|
|
183
|
+
passkeys = (res ?? []) as Passkey[];
|
|
184
|
+
}
|
|
185
|
+
async function registerPasskey(event: SubmitEvent): Promise<void> {
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
passkeyBusy = true;
|
|
188
|
+
// Triggers the browser WebAuthn ceremony (navigator.credentials.create).
|
|
189
|
+
const { error } = await api.auth.passkey.addPasskey({ name: newPasskeyName.trim() || undefined });
|
|
190
|
+
passkeyBusy = false;
|
|
191
|
+
if (error) return void toast.error(error.message ?? i18n.t.passkeys.addFailed);
|
|
192
|
+
newPasskeyName = "";
|
|
193
|
+
toast.success(i18n.t.passkeys.added);
|
|
194
|
+
await loadPasskeys();
|
|
195
|
+
}
|
|
196
|
+
async function deletePasskey(id: string): Promise<void> {
|
|
197
|
+
passkeyBusy = true;
|
|
198
|
+
const { error } = await api.auth.passkey.deletePasskey({ id });
|
|
199
|
+
passkeyBusy = false;
|
|
200
|
+
if (error) return void toast.error(error.message ?? i18n.t.passkeys.removeFailed);
|
|
201
|
+
toast.success(i18n.t.passkeys.removed);
|
|
202
|
+
await loadPasskeys();
|
|
203
|
+
}
|
|
204
|
+
|
|
99
205
|
// Connected accounts
|
|
100
206
|
type Account = { id: string; providerId: string; accountId: string };
|
|
101
207
|
let accounts = $state<Account[]>([]);
|
|
@@ -135,15 +241,28 @@
|
|
|
135
241
|
|
|
136
242
|
// Sessions — this user's own devices
|
|
137
243
|
type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
|
|
138
|
-
const PAGE_SIZE =
|
|
244
|
+
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
139
245
|
let sessions = $state<Session[]>([]);
|
|
140
246
|
let sessionsPage = $state(1);
|
|
247
|
+
let sessionsSort = $state<SortState | null>(null);
|
|
141
248
|
let busy = $state(false);
|
|
142
|
-
const
|
|
249
|
+
const sessionsColumns: DataTableColumn<Session>[] = [
|
|
250
|
+
{ key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => s.userAgent ?? "" },
|
|
251
|
+
{ key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
|
|
252
|
+
{ key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
|
|
253
|
+
{ key: "status", label: i18n.t.sessions.status },
|
|
254
|
+
{ key: "actions", label: "", class: "w-10" },
|
|
255
|
+
];
|
|
143
256
|
async function loadSessions(): Promise<void> {
|
|
144
257
|
const { data: res, error } = await api.auth.listSessions();
|
|
145
258
|
if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
|
|
146
|
-
|
|
259
|
+
// Current session first (users expect it on top; also keeps it on page 1),
|
|
260
|
+
// then newest to oldest.
|
|
261
|
+
sessions = ((res ?? []) as Session[]).sort((a, b) => {
|
|
262
|
+
if (a.id === data.currentSessionId) return -1;
|
|
263
|
+
if (b.id === data.currentSessionId) return 1;
|
|
264
|
+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
265
|
+
});
|
|
147
266
|
}
|
|
148
267
|
async function revoke(token: string): Promise<void> {
|
|
149
268
|
busy = true;
|
|
@@ -166,9 +285,71 @@
|
|
|
166
285
|
}
|
|
167
286
|
}
|
|
168
287
|
|
|
288
|
+
// Multi-account: other accounts signed in on this browser (multiSession plugin).
|
|
289
|
+
type DeviceSession = { session: { token: string }; user: { id: string; email: string; name: string } };
|
|
290
|
+
let deviceSessions = $state<DeviceSession[]>([]);
|
|
291
|
+
async function loadDeviceSessions(): Promise<void> {
|
|
292
|
+
if (!caps.multiSession) return;
|
|
293
|
+
const { data: res } = await api.auth.multiSession.listDeviceSessions();
|
|
294
|
+
deviceSessions = (res ?? []) as DeviceSession[];
|
|
295
|
+
}
|
|
296
|
+
async function switchAccount(token: string): Promise<void> {
|
|
297
|
+
busy = true;
|
|
298
|
+
const { error } = await api.auth.multiSession.setActive({ sessionToken: token });
|
|
299
|
+
busy = false;
|
|
300
|
+
if (error) return void toast.error(error.message ?? i18n.t.sessions.switchFailed);
|
|
301
|
+
await goto("/admin", { invalidateAll: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Personal API keys (apiKey plugin). The full key is shown once on creation.
|
|
305
|
+
type ApiKey = { id: string; name: string | null; start: string | null; createdAt: string | Date; expiresAt: string | Date | null };
|
|
306
|
+
let apiKeys = $state<ApiKey[]>([]);
|
|
307
|
+
let apiKeysPage = $state(1);
|
|
308
|
+
let apiKeysSort = $state<SortState | null>(null);
|
|
309
|
+
const apiKeysColumns: DataTableColumn<ApiKey>[] = [
|
|
310
|
+
{ key: "name", label: i18n.t.apiKeys.name, sortable: true, value: (k) => k.name ?? "" },
|
|
311
|
+
{ key: "keyColumn", label: i18n.t.apiKeys.keyColumn },
|
|
312
|
+
{ key: "createdAt", label: i18n.t.apiKeys.created, sortable: true, value: (k) => new Date(k.createdAt).getTime() },
|
|
313
|
+
{ key: "actions", label: "", class: "w-10" },
|
|
314
|
+
];
|
|
315
|
+
let newKeyName = $state("");
|
|
316
|
+
let createdKey = $state<string | null>(null);
|
|
317
|
+
let keyBusy = $state(false);
|
|
318
|
+
async function loadApiKeys(): Promise<void> {
|
|
319
|
+
if (!caps.apiKey) return;
|
|
320
|
+
const { data: res } = await api.auth.apiKey.list();
|
|
321
|
+
// the endpoint returns { apiKeys: [...] }; tolerate a bare array too
|
|
322
|
+
apiKeys = (Array.isArray(res) ? res : ((res as { apiKeys?: ApiKey[] } | null)?.apiKeys ?? [])) as ApiKey[];
|
|
323
|
+
}
|
|
324
|
+
async function createApiKey(event: SubmitEvent): Promise<void> {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
keyBusy = true;
|
|
327
|
+
const { data: res, error } = await api.auth.apiKey.create({ name: newKeyName.trim() || undefined });
|
|
328
|
+
keyBusy = false;
|
|
329
|
+
if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
|
|
330
|
+
createdKey = res?.key ?? null;
|
|
331
|
+
newKeyName = "";
|
|
332
|
+
await loadApiKeys();
|
|
333
|
+
}
|
|
334
|
+
async function deleteApiKey(keyId: string): Promise<void> {
|
|
335
|
+
keyBusy = true;
|
|
336
|
+
const { error } = await api.auth.apiKey.delete({ keyId });
|
|
337
|
+
keyBusy = false;
|
|
338
|
+
if (error) return void toast.error(error.message ?? i18n.t.apiKeys.revokeFailed);
|
|
339
|
+
toast.success(i18n.t.apiKeys.revoked);
|
|
340
|
+
await loadApiKeys();
|
|
341
|
+
}
|
|
342
|
+
async function copyKey(): Promise<void> {
|
|
343
|
+
if (createdKey) await navigator.clipboard.writeText(createdKey).catch(() => undefined);
|
|
344
|
+
toast.success(i18n.t.apiKeys.copied);
|
|
345
|
+
}
|
|
346
|
+
|
|
169
347
|
$effect(() => {
|
|
170
348
|
void loadSessions();
|
|
171
349
|
void loadAccounts();
|
|
350
|
+
void loadDeviceSessions();
|
|
351
|
+
void loadApiKeys();
|
|
352
|
+
void loadPasskeys();
|
|
172
353
|
});
|
|
173
354
|
</script>
|
|
174
355
|
|
|
@@ -178,14 +359,14 @@
|
|
|
178
359
|
<div class="flex flex-col gap-6 lg:flex-row">
|
|
179
360
|
<nav class="flex shrink-0 flex-wrap gap-1 lg:w-48 lg:flex-col">
|
|
180
361
|
{#each navItems as key (key)}
|
|
181
|
-
<
|
|
182
|
-
|
|
362
|
+
<Button
|
|
363
|
+
variant="ghost"
|
|
183
364
|
onclick={() => (section = key)}
|
|
184
365
|
aria-current={section === key ? "page" : undefined}
|
|
185
|
-
class="
|
|
366
|
+
class="h-auto justify-start px-3 py-2 text-sm font-medium {section === key ? 'bg-muted' : 'text-muted-foreground'}"
|
|
186
367
|
>
|
|
187
368
|
{i18n.t.account.nav[key]}
|
|
188
|
-
</
|
|
369
|
+
</Button>
|
|
189
370
|
{/each}
|
|
190
371
|
</nav>
|
|
191
372
|
|
|
@@ -199,13 +380,33 @@
|
|
|
199
380
|
<Label for="name">{i18n.t.account.name}</Label>
|
|
200
381
|
<Input id="name" bind:value={name} required />
|
|
201
382
|
</div>
|
|
383
|
+
{#if caps.username}
|
|
384
|
+
<div class="flex flex-col gap-2">
|
|
385
|
+
<Label for="username">{i18n.t.account.username}</Label>
|
|
386
|
+
<Input id="username" bind:value={username} autocomplete="username" />
|
|
387
|
+
</div>
|
|
388
|
+
{/if}
|
|
202
389
|
<div class="flex flex-col gap-2">
|
|
203
390
|
<Label for="email">{i18n.t.account.email}</Label>
|
|
204
391
|
<div class="flex items-center gap-2">
|
|
205
|
-
<Input id="email" value={
|
|
206
|
-
|
|
207
|
-
{data.user.emailVerified ?
|
|
208
|
-
|
|
392
|
+
<Input id="email" type="email" bind:value={newEmail} required />
|
|
393
|
+
{#if caps.emailVerification}
|
|
394
|
+
<Badge variant={data.user.emailVerified ? "outline" : "secondary"} class={data.user.emailVerified ? "text-green-600 dark:text-green-400" : ""}>
|
|
395
|
+
{data.user.emailVerified ? i18n.t.account.verified : i18n.t.account.unverified}
|
|
396
|
+
</Badge>
|
|
397
|
+
{/if}
|
|
398
|
+
</div>
|
|
399
|
+
<div class="flex flex-wrap gap-2">
|
|
400
|
+
{#if emailChanged}
|
|
401
|
+
<Button type="button" variant="outline" size="sm" class="w-fit" disabled={changingEmail} onclick={changeEmail}>
|
|
402
|
+
{changingEmail ? i18n.t.account.saving : i18n.t.account.changeEmail}
|
|
403
|
+
</Button>
|
|
404
|
+
{/if}
|
|
405
|
+
{#if caps.emailVerification && !data.user.emailVerified}
|
|
406
|
+
<Button type="button" variant="outline" size="sm" class="w-fit" disabled={verifying} onclick={resendVerification}>
|
|
407
|
+
{verifying ? i18n.t.auth.sending : i18n.t.auth.resendVerification}
|
|
408
|
+
</Button>
|
|
409
|
+
{/if}
|
|
209
410
|
</div>
|
|
210
411
|
</div>
|
|
211
412
|
<div class="flex flex-col gap-2">
|
|
@@ -214,6 +415,25 @@
|
|
|
214
415
|
</div>
|
|
215
416
|
<Button type="submit" class="w-fit" disabled={savingProfile || !nameChanged}>{i18n.t.account.save}</Button>
|
|
216
417
|
</form>
|
|
418
|
+
{#if caps.phoneNumber}
|
|
419
|
+
<div class="mt-6 flex max-w-md flex-col gap-2 border-t pt-6">
|
|
420
|
+
<div class="flex items-center gap-2">
|
|
421
|
+
<Label for="phone">{i18n.t.account.phone}</Label>
|
|
422
|
+
{#if data.user.phoneNumberVerified}
|
|
423
|
+
<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.account.verified}</Badge>
|
|
424
|
+
{/if}
|
|
425
|
+
</div>
|
|
426
|
+
<Input id="phone" type="tel" bind:value={phone} autocomplete="tel" />
|
|
427
|
+
{#if phoneOtpSent}
|
|
428
|
+
<Input placeholder={i18n.t.account.phoneCode} bind:value={phoneCode} inputmode="numeric" autocomplete="one-time-code" />
|
|
429
|
+
<Button type="button" size="sm" class="w-fit" disabled={phoneBusy || !phoneCode} onclick={verifyPhone}>{i18n.t.account.phoneVerify}</Button>
|
|
430
|
+
{:else}
|
|
431
|
+
<Button type="button" variant="outline" size="sm" class="w-fit" disabled={phoneBusy || !phone} onclick={sendPhoneOtp}>
|
|
432
|
+
{phoneBusy ? i18n.t.auth.sending : i18n.t.account.phoneSendCode}
|
|
433
|
+
</Button>
|
|
434
|
+
{/if}
|
|
435
|
+
</div>
|
|
436
|
+
{/if}
|
|
217
437
|
</Card.Content>
|
|
218
438
|
</Card.Root>
|
|
219
439
|
{:else if section === "security"}
|
|
@@ -253,6 +473,9 @@
|
|
|
253
473
|
<Button variant="destructive" class="w-fit" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
|
|
254
474
|
{:else if setup}
|
|
255
475
|
<p class="text-muted-foreground text-sm">{i18n.t.account.scanHint}</p>
|
|
476
|
+
{#if qrDataUrl}
|
|
477
|
+
<img src={qrDataUrl} alt="TOTP QR code" width="176" height="176" class="rounded border bg-white p-2" />
|
|
478
|
+
{/if}
|
|
256
479
|
<div class="flex flex-col gap-1">
|
|
257
480
|
<Label>{i18n.t.account.setupKey}</Label>
|
|
258
481
|
<code class="bg-muted overflow-x-auto rounded px-2 py-1 text-xs">{setup.totpURI}</code>
|
|
@@ -278,6 +501,41 @@
|
|
|
278
501
|
</Card.Content>
|
|
279
502
|
</Card.Root>
|
|
280
503
|
{/if}
|
|
504
|
+
{#if caps.passkey}
|
|
505
|
+
<Card.Root>
|
|
506
|
+
<Card.Header>
|
|
507
|
+
<Card.Title>{i18n.t.passkeys.title}</Card.Title>
|
|
508
|
+
<Card.Description>{i18n.t.passkeys.subtitle}</Card.Description>
|
|
509
|
+
</Card.Header>
|
|
510
|
+
<Card.Content class="flex flex-col gap-4">
|
|
511
|
+
<form class="flex items-end gap-2" onsubmit={registerPasskey}>
|
|
512
|
+
<div class="flex flex-1 flex-col gap-2">
|
|
513
|
+
<Label for="passkey-name">{i18n.t.passkeys.name}</Label>
|
|
514
|
+
<Input id="passkey-name" bind:value={newPasskeyName} placeholder={i18n.t.passkeys.namePlaceholder} />
|
|
515
|
+
</div>
|
|
516
|
+
<Button type="submit" disabled={passkeyBusy}>{i18n.t.passkeys.add}</Button>
|
|
517
|
+
</form>
|
|
518
|
+
<DataTable
|
|
519
|
+
columns={passkeysColumns}
|
|
520
|
+
rows={passkeys}
|
|
521
|
+
getKey={(pk) => pk.id}
|
|
522
|
+
bind:sort={passkeysSort}
|
|
523
|
+
bind:page={passkeysPage}
|
|
524
|
+
perPage={PAGE_SIZE}
|
|
525
|
+
label={fmt(i18n.t.passkeys.total, { count: passkeys.length })}
|
|
526
|
+
empty={i18n.t.passkeys.empty}
|
|
527
|
+
>
|
|
528
|
+
{#snippet row(pk)}
|
|
529
|
+
<Table.Cell class="font-medium">{pk.name || i18n.t.passkeys.untitled}</Table.Cell>
|
|
530
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(pk.createdAt)}</Table.Cell>
|
|
531
|
+
<Table.Cell>
|
|
532
|
+
<Button variant="ghost" size="sm" disabled={passkeyBusy} onclick={() => deletePasskey(pk.id)}>{i18n.t.passkeys.remove}</Button>
|
|
533
|
+
</Table.Cell>
|
|
534
|
+
{/snippet}
|
|
535
|
+
</DataTable>
|
|
536
|
+
</Card.Content>
|
|
537
|
+
</Card.Root>
|
|
538
|
+
{/if}
|
|
281
539
|
</div>
|
|
282
540
|
{:else if section === "connected"}
|
|
283
541
|
<Card.Root>
|
|
@@ -303,46 +561,99 @@
|
|
|
303
561
|
</Card.Content>
|
|
304
562
|
</Card.Root>
|
|
305
563
|
{:else if section === "sessions"}
|
|
564
|
+
{#if caps.multiSession && deviceSessions.filter((d) => d.user.id !== data.user.id).length}
|
|
565
|
+
<Card.Root>
|
|
566
|
+
<Card.Header><Card.Title>{i18n.t.sessions.accountsTitle}</Card.Title></Card.Header>
|
|
567
|
+
<Card.Content class="flex flex-col gap-2">
|
|
568
|
+
{#each deviceSessions.filter((d) => d.user.id !== data.user.id) as d (d.session.token)}
|
|
569
|
+
<div class="flex items-center justify-between gap-4 rounded-md border p-3">
|
|
570
|
+
<div>
|
|
571
|
+
<div class="font-medium">{d.user.name}</div>
|
|
572
|
+
<div class="text-muted-foreground text-xs">{d.user.email}</div>
|
|
573
|
+
</div>
|
|
574
|
+
<Button variant="outline" size="sm" disabled={busy} onclick={() => switchAccount(d.session.token)}>{i18n.t.sessions.switchAccount}</Button>
|
|
575
|
+
</div>
|
|
576
|
+
{/each}
|
|
577
|
+
</Card.Content>
|
|
578
|
+
</Card.Root>
|
|
579
|
+
{/if}
|
|
306
580
|
<Card.Root>
|
|
307
581
|
<Card.Header class="flex flex-row items-center justify-between gap-2">
|
|
308
582
|
<Card.Title>{i18n.t.sessions.title}</Card.Title>
|
|
309
583
|
<Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
|
|
310
584
|
</Card.Header>
|
|
311
585
|
<Card.Content>
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
586
|
+
<DataTable
|
|
587
|
+
columns={sessionsColumns}
|
|
588
|
+
rows={sessions}
|
|
589
|
+
getKey={(s) => s.id}
|
|
590
|
+
bind:sort={sessionsSort}
|
|
591
|
+
bind:page={sessionsPage}
|
|
592
|
+
perPage={PAGE_SIZE}
|
|
593
|
+
label={fmt(i18n.t.sessions.total, { count: sessions.length })}
|
|
594
|
+
empty={i18n.t.sessions.empty}
|
|
595
|
+
>
|
|
596
|
+
{#snippet row(s)}
|
|
597
|
+
{@const isCurrent = s.id === data.currentSessionId}
|
|
598
|
+
<Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
|
|
599
|
+
<Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
|
|
600
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
|
|
601
|
+
<Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
|
|
602
|
+
<Table.Cell>
|
|
603
|
+
<Button variant="ghost" size="sm" disabled={busy || isCurrent} onclick={() => revoke(s.token)}>{i18n.t.sessions.revoke}</Button>
|
|
604
|
+
</Table.Cell>
|
|
605
|
+
{/snippet}
|
|
606
|
+
</DataTable>
|
|
607
|
+
</Card.Content>
|
|
608
|
+
</Card.Root>
|
|
609
|
+
{:else if section === "apiKeys"}
|
|
610
|
+
<Card.Root>
|
|
611
|
+
<Card.Header>
|
|
612
|
+
<Card.Title>{i18n.t.apiKeys.title}</Card.Title>
|
|
613
|
+
<Card.Description>{i18n.t.apiKeys.subtitle}</Card.Description>
|
|
614
|
+
</Card.Header>
|
|
615
|
+
<Card.Content class="flex flex-col gap-4">
|
|
616
|
+
<form class="flex items-end gap-2" onsubmit={createApiKey}>
|
|
617
|
+
<div class="flex flex-1 flex-col gap-2">
|
|
618
|
+
<Label for="key-name">{i18n.t.apiKeys.name}</Label>
|
|
619
|
+
<Input id="key-name" bind:value={newKeyName} placeholder={i18n.t.apiKeys.namePlaceholder} />
|
|
620
|
+
</div>
|
|
621
|
+
<Button type="submit" disabled={keyBusy}>{i18n.t.apiKeys.create}</Button>
|
|
622
|
+
</form>
|
|
623
|
+
<DataTable
|
|
624
|
+
columns={apiKeysColumns}
|
|
625
|
+
rows={apiKeys}
|
|
626
|
+
getKey={(k) => k.id}
|
|
627
|
+
bind:sort={apiKeysSort}
|
|
628
|
+
bind:page={apiKeysPage}
|
|
629
|
+
perPage={PAGE_SIZE}
|
|
630
|
+
label={fmt(i18n.t.apiKeys.total, { count: apiKeys.length })}
|
|
631
|
+
empty={i18n.t.apiKeys.empty}
|
|
632
|
+
>
|
|
633
|
+
{#snippet row(k)}
|
|
634
|
+
<Table.Cell class="font-medium">{k.name || i18n.t.apiKeys.untitled}</Table.Cell>
|
|
635
|
+
<Table.Cell class="text-muted-foreground font-mono text-xs">{k.start ? `${k.start}…` : "—"}</Table.Cell>
|
|
636
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(k.createdAt)}</Table.Cell>
|
|
637
|
+
<Table.Cell>
|
|
638
|
+
<Button variant="ghost" size="sm" disabled={keyBusy} onclick={() => deleteApiKey(k.id)}>{i18n.t.apiKeys.revoke}</Button>
|
|
639
|
+
</Table.Cell>
|
|
640
|
+
{/snippet}
|
|
641
|
+
</DataTable>
|
|
344
642
|
</Card.Content>
|
|
345
643
|
</Card.Root>
|
|
644
|
+
<Dialog.Root open={createdKey !== null} onOpenChange={(v) => { if (!v) createdKey = null; }}>
|
|
645
|
+
<Dialog.Content>
|
|
646
|
+
<Dialog.Header>
|
|
647
|
+
<Dialog.Title>{i18n.t.apiKeys.createdTitle}</Dialog.Title>
|
|
648
|
+
<Dialog.Description>{i18n.t.apiKeys.createdDesc}</Dialog.Description>
|
|
649
|
+
</Dialog.Header>
|
|
650
|
+
<div class="bg-muted rounded-md p-3 font-mono text-sm break-all">{createdKey}</div>
|
|
651
|
+
<Dialog.Footer>
|
|
652
|
+
<Button variant="outline" onclick={copyKey}>{i18n.t.apiKeys.copy}</Button>
|
|
653
|
+
<Button onclick={() => (createdKey = null)}>{i18n.t.apiKeys.done}</Button>
|
|
654
|
+
</Dialog.Footer>
|
|
655
|
+
</Dialog.Content>
|
|
656
|
+
</Dialog.Root>
|
|
346
657
|
{:else if section === "danger"}
|
|
347
658
|
<Card.Root class="border-destructive/50">
|
|
348
659
|
<Card.Header>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { PageServerLoad } from "./$types";
|
|
2
|
-
import {
|
|
2
|
+
import { requireAdmin } from "$lib/server/guards";
|
|
3
3
|
|
|
4
4
|
export const load: PageServerLoad = ({ locals }) => {
|
|
5
|
-
|
|
6
|
-
error(403, "Admins only");
|
|
7
|
-
}
|
|
5
|
+
requireAdmin(locals.user);
|
|
8
6
|
};
|