@podosoft/podokit 0.9.0 → 0.10.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.
Files changed (66) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +32 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -717
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  46. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
  49. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  50. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  51. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  52. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  53. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  54. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  55. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  56. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  59. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  60. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  61. package/dist/templates/todo/dot-gitignore +6 -0
  62. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  63. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  64. package/dist/update.js +74 -8
  65. package/package.json +1 -1
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -0,0 +1,730 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import QRCode from "qrcode";
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 { Badge } from "$lib/components/ui/badge";
8
+ import * as Card from "$lib/components/ui/card";
9
+ import * as Table from "$lib/components/ui/table";
10
+ import * as Dialog from "$lib/components/ui/dialog";
11
+ import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
12
+ import { toast } from "svelte-sonner";
13
+ import { api } from "$lib/api";
14
+ import { getI18n, fmt, formatDateTime } from "$lib/i18n";
15
+ import { untrack } from "svelte";
16
+ import type { SessionUser } from "../../app.d.ts";
17
+ import type { Capabilities } from "@podosoft/podokit-api-client";
18
+
19
+ let {
20
+ data,
21
+ accountPath = "/account",
22
+ returnPath = "/",
23
+ }: {
24
+ data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities };
25
+ accountPath?: string;
26
+ returnPath?: string;
27
+ } = $props();
28
+ const i18n = getI18n();
29
+
30
+ // Optional features (resolved by the layout) so we only show enabled sections.
31
+ const caps = $derived(data.capabilities);
32
+
33
+ type SectionKey = "profile" | "security" | "connected" | "sessions" | "apiKeys" | "danger";
34
+ let section = $state<SectionKey>("profile");
35
+ const navItems = $derived<SectionKey[]>([
36
+ "profile",
37
+ "security",
38
+ ...(caps.providers.length ? (["connected"] as const) : []),
39
+ "sessions",
40
+ ...(caps.apiKey ? (["apiKeys"] as const) : []),
41
+ ...(caps.deleteAccount ? (["danger"] as const) : []),
42
+ ]);
43
+
44
+ // Profile — seed the editable fields from the initial user value.
45
+ let name = $state(untrack(() => data.user.name));
46
+ let username = $state(untrack(() => data.user.username ?? ""));
47
+ let savingProfile = $state(false);
48
+ const nameChanged = $derived(
49
+ (name.trim() !== "" && name !== data.user.name) || (caps.username && username !== (data.user.username ?? "")),
50
+ );
51
+ async function saveProfile(event: SubmitEvent): Promise<void> {
52
+ event.preventDefault();
53
+ savingProfile = true;
54
+ // Only send username when it's set and actually changed — updateUser rejects
55
+ // an empty username, which would block name-only edits for users without one.
56
+ const usernameChanged = caps.username && username.trim() !== "" && username !== (data.user.username ?? "");
57
+ const { error } = await api.auth.updateUser({ name, ...(usernameChanged ? { username: username.trim() } : {}) });
58
+ savingProfile = false;
59
+ if (error) toast.error(error.message ?? i18n.t.account.saveFailed);
60
+ else toast.success(i18n.t.account.saved);
61
+ }
62
+
63
+ // Phone number — register + verify with an SMS code (dev delivery is a stub).
64
+ let phone = $state(untrack(() => data.user.phoneNumber ?? ""));
65
+ let phoneCode = $state("");
66
+ let phoneOtpSent = $state(false);
67
+ let phoneBusy = $state(false);
68
+ async function sendPhoneOtp(): Promise<void> {
69
+ phoneBusy = true;
70
+ const { error } = await api.auth.phoneNumber.sendOtp({ phoneNumber: phone });
71
+ phoneBusy = false;
72
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
73
+ phoneOtpSent = true;
74
+ toast.success(i18n.t.account.phoneCodeSent);
75
+ }
76
+ async function verifyPhone(): Promise<void> {
77
+ phoneBusy = true;
78
+ // updatePhoneNumber: save the verified number on the current user (without it,
79
+ // verify follows the sign-in path and never associates the phone).
80
+ const { error } = await api.auth.phoneNumber.verify({ phoneNumber: phone, code: phoneCode, updatePhoneNumber: true });
81
+ phoneBusy = false;
82
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
83
+ phoneOtpSent = false;
84
+ phoneCode = "";
85
+ toast.success(i18n.t.account.phoneVerified);
86
+ await goto(accountPath, { invalidateAll: true });
87
+ }
88
+
89
+ // Email change — when verification is on, better-auth emails an approval link
90
+ // to the current address; otherwise the address switches immediately.
91
+ let newEmail = $state(untrack(() => data.user.email));
92
+ let changingEmail = $state(false);
93
+ const emailChanged = $derived(newEmail.trim() !== "" && newEmail !== data.user.email);
94
+ async function changeEmail(): Promise<void> {
95
+ changingEmail = true;
96
+ const { error } = await api.auth.changeEmail({ newEmail, callbackURL: `${location.origin}${returnPath}` });
97
+ changingEmail = false;
98
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
99
+ toast.success(caps.emailVerification ? i18n.t.account.emailChangePending : i18n.t.account.emailChanged);
100
+ }
101
+
102
+ // Email verification — resend the confirmation link to the current address.
103
+ let verifying = $state(false);
104
+ async function resendVerification(): Promise<void> {
105
+ verifying = true;
106
+ const { error } = await api.auth.sendVerificationEmail({ email: data.user.email, callbackURL: `${location.origin}${returnPath}` });
107
+ verifying = false;
108
+ if (error) toast.error(error.message ?? i18n.t.auth.requestFailed);
109
+ else toast.success(i18n.t.auth.verifySent);
110
+ }
111
+
112
+ // Security — change password
113
+ let currentPassword = $state("");
114
+ let newPassword = $state("");
115
+ let changing = $state(false);
116
+ async function changePassword(event: SubmitEvent): Promise<void> {
117
+ event.preventDefault();
118
+ changing = true;
119
+ const { error } = await api.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
120
+ changing = false;
121
+ if (error) toast.error(error.message ?? i18n.t.account.changeFailed);
122
+ else {
123
+ toast.success(i18n.t.account.changed);
124
+ currentPassword = "";
125
+ newPassword = "";
126
+ await loadSessions();
127
+ }
128
+ }
129
+
130
+ // Security — two-factor
131
+ let twoFaOn = $state<boolean>(untrack(() => data.user.twoFactorEnabled ?? false));
132
+ let twoFaPassword = $state("");
133
+ let twoFaCode = $state("");
134
+ let twoFaBusy = $state(false);
135
+ let setup = $state<{ totpURI: string; backupCodes: string[] } | null>(null);
136
+ // Render the otpauth URI as a scannable QR image (data URL) during setup.
137
+ let qrDataUrl = $state("");
138
+ $effect(() => {
139
+ const uri = setup?.totpURI;
140
+ if (!uri) {
141
+ qrDataUrl = "";
142
+ return;
143
+ }
144
+ QRCode.toDataURL(uri, { margin: 1, width: 176 })
145
+ .then((url) => (qrDataUrl = url))
146
+ .catch(() => (qrDataUrl = ""));
147
+ });
148
+ async function enable2fa(): Promise<void> {
149
+ twoFaBusy = true;
150
+ const { data: res, error } = await api.auth.twoFactor.enable({ password: twoFaPassword });
151
+ twoFaBusy = false;
152
+ if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
153
+ setup = { totpURI: res?.totpURI ?? "", backupCodes: (res?.backupCodes ?? []) as string[] };
154
+ }
155
+ async function verify2fa(): Promise<void> {
156
+ twoFaBusy = true;
157
+ const { error } = await api.auth.twoFactor.verifyTotp({ code: twoFaCode });
158
+ twoFaBusy = false;
159
+ if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
160
+ twoFaOn = true;
161
+ setup = null;
162
+ twoFaPassword = "";
163
+ twoFaCode = "";
164
+ toast.success(i18n.t.account.twoFactorEnabled);
165
+ }
166
+ async function disable2fa(): Promise<void> {
167
+ twoFaBusy = true;
168
+ const { error } = await api.auth.twoFactor.disable({ password: twoFaPassword });
169
+ twoFaBusy = false;
170
+ if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
171
+ twoFaOn = false;
172
+ twoFaPassword = "";
173
+ regenCodes = null;
174
+ toast.success(i18n.t.account.twoFactorDisabled);
175
+ }
176
+ // Backup codes: regenerate a fresh set (invalidating the old ones) and download.
177
+ let regenCodes = $state<string[] | null>(null);
178
+ async function regenerateBackupCodes(): Promise<void> {
179
+ twoFaBusy = true;
180
+ const { data: res, error } = await api.auth.twoFactor.generateBackupCodes({ password: twoFaPassword });
181
+ twoFaBusy = false;
182
+ if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
183
+ regenCodes = (res?.backupCodes ?? []) as string[];
184
+ twoFaPassword = "";
185
+ toast.success(i18n.t.account.backupCodesRegenerated);
186
+ }
187
+ function downloadBackupCodes(codes: string[]): void {
188
+ const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
189
+ const url = URL.createObjectURL(blob);
190
+ const a = document.createElement("a");
191
+ a.href = url;
192
+ a.download = "backup-codes.txt";
193
+ a.click();
194
+ URL.revokeObjectURL(url);
195
+ }
196
+
197
+ // Passkeys (WebAuthn) — passwordless credentials bound to this device/browser.
198
+ type Passkey = { id: string; name: string | null; createdAt: string | Date };
199
+ let passkeys = $state<Passkey[]>([]);
200
+ let passkeysPage = $state(1);
201
+ let passkeysSort = $state<SortState | null>(null);
202
+ const passkeysColumns: DataTableColumn<Passkey>[] = [
203
+ { key: "name", label: i18n.t.passkeys.name, sortable: true, value: (p) => p.name ?? "" },
204
+ { key: "createdAt", label: i18n.t.passkeys.created, sortable: true, value: (p) => new Date(p.createdAt).getTime() },
205
+ { key: "actions", label: "", class: "w-10" },
206
+ ];
207
+ let newPasskeyName = $state("");
208
+ let passkeyBusy = $state(false);
209
+ async function loadPasskeys(): Promise<void> {
210
+ if (!caps.passkey) return;
211
+ const { data: res } = await api.auth.passkey.listUserPasskeys();
212
+ passkeys = (res ?? []) as Passkey[];
213
+ }
214
+ async function registerPasskey(event: SubmitEvent): Promise<void> {
215
+ event.preventDefault();
216
+ passkeyBusy = true;
217
+ // Triggers the browser WebAuthn ceremony (navigator.credentials.create).
218
+ const { error } = await api.auth.passkey.addPasskey({ name: newPasskeyName.trim() || undefined });
219
+ passkeyBusy = false;
220
+ if (error) return void toast.error(error.message ?? i18n.t.passkeys.addFailed);
221
+ newPasskeyName = "";
222
+ toast.success(i18n.t.passkeys.added);
223
+ await loadPasskeys();
224
+ }
225
+ async function deletePasskey(id: string): Promise<void> {
226
+ passkeyBusy = true;
227
+ const { error } = await api.auth.passkey.deletePasskey({ id });
228
+ passkeyBusy = false;
229
+ if (error) return void toast.error(error.message ?? i18n.t.passkeys.removeFailed);
230
+ toast.success(i18n.t.passkeys.removed);
231
+ await loadPasskeys();
232
+ }
233
+
234
+ // Connected accounts
235
+ type Account = { id: string; providerId: string; accountId: string };
236
+ let accounts = $state<Account[]>([]);
237
+ async function loadAccounts(): Promise<void> {
238
+ const { data: res } = await api.auth.listAccounts();
239
+ accounts = (res ?? []) as Account[];
240
+ }
241
+ const linkedProviders = $derived(new Set(accounts.map((a) => a.providerId)));
242
+ async function connect(provider: string): Promise<void> {
243
+ const { data: res, error } = await api.auth.linkSocial({ provider, callbackURL: accountPath });
244
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
245
+ if (res?.url) window.location.href = res.url;
246
+ }
247
+ async function disconnect(provider: string): Promise<void> {
248
+ const account = accounts.find((a) => a.providerId === provider);
249
+ if (!account) return;
250
+ const { error } = await api.auth.unlinkAccount({ providerId: provider, accountId: account.accountId });
251
+ if (error) toast.error(error.message ?? i18n.t.account.saveFailed);
252
+ else {
253
+ toast.success(i18n.t.account.disconnected);
254
+ await loadAccounts();
255
+ }
256
+ }
257
+
258
+ // Danger — delete account
259
+ let deleteOpen = $state(false);
260
+ let deletePassword = $state("");
261
+ let deleteBusy = $state(false);
262
+ async function deleteAccount(): Promise<void> {
263
+ deleteBusy = true;
264
+ const { error } = await api.auth.deleteUser({ password: deletePassword });
265
+ deleteBusy = false;
266
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
267
+ toast.success(i18n.t.account.accountDeleted);
268
+ await goto("/login", { invalidateAll: true });
269
+ }
270
+
271
+ // Sessions — this user's own devices
272
+ type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
273
+ const PAGE_SIZE = DEFAULT_PAGE_SIZE;
274
+ let sessions = $state<Session[]>([]);
275
+ let sessionsPage = $state(1);
276
+ let sessionsSort = $state<SortState | null>(null);
277
+ let busy = $state(false);
278
+ const sessionsColumns: DataTableColumn<Session>[] = [
279
+ { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => s.userAgent ?? "" },
280
+ { key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
281
+ { key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
282
+ { key: "status", label: i18n.t.sessions.status },
283
+ { key: "actions", label: "", class: "w-10" },
284
+ ];
285
+ async function loadSessions(): Promise<void> {
286
+ const { data: res, error } = await api.auth.listSessions();
287
+ if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
288
+ // Current session first (users expect it on top; also keeps it on page 1),
289
+ // then newest to oldest.
290
+ sessions = ((res ?? []) as Session[]).sort((a, b) => {
291
+ if (a.id === data.currentSessionId) return -1;
292
+ if (b.id === data.currentSessionId) return 1;
293
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
294
+ });
295
+ }
296
+ async function revoke(token: string): Promise<void> {
297
+ busy = true;
298
+ const { error } = await api.auth.revokeSession({ token });
299
+ busy = false;
300
+ if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
301
+ else {
302
+ toast.success(i18n.t.sessions.revoked);
303
+ await loadSessions();
304
+ }
305
+ }
306
+ async function signOutOthers(): Promise<void> {
307
+ busy = true;
308
+ const { error } = await api.auth.revokeOtherSessions();
309
+ busy = false;
310
+ if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
311
+ else {
312
+ toast.success(i18n.t.sessions.othersRevoked);
313
+ await loadSessions();
314
+ }
315
+ }
316
+
317
+ // Multi-account: other accounts signed in on this browser (multiSession plugin).
318
+ type DeviceSession = { session: { token: string }; user: { id: string; email: string; name: string } };
319
+ let deviceSessions = $state<DeviceSession[]>([]);
320
+ async function loadDeviceSessions(): Promise<void> {
321
+ if (!caps.multiSession) return;
322
+ const { data: res } = await api.auth.multiSession.listDeviceSessions();
323
+ deviceSessions = (res ?? []) as DeviceSession[];
324
+ }
325
+ async function switchAccount(token: string): Promise<void> {
326
+ busy = true;
327
+ const { error } = await api.auth.multiSession.setActive({ sessionToken: token });
328
+ busy = false;
329
+ if (error) return void toast.error(error.message ?? i18n.t.sessions.switchFailed);
330
+ await goto(returnPath, { invalidateAll: true });
331
+ }
332
+
333
+ // Personal API keys (apiKey plugin). The full key is shown once on creation.
334
+ type ApiKey = { id: string; name: string | null; start: string | null; createdAt: string | Date; expiresAt: string | Date | null };
335
+ let apiKeys = $state<ApiKey[]>([]);
336
+ let apiKeysPage = $state(1);
337
+ let apiKeysSort = $state<SortState | null>(null);
338
+ const apiKeysColumns: DataTableColumn<ApiKey>[] = [
339
+ { key: "name", label: i18n.t.apiKeys.name, sortable: true, value: (k) => k.name ?? "" },
340
+ { key: "keyColumn", label: i18n.t.apiKeys.keyColumn },
341
+ { key: "createdAt", label: i18n.t.apiKeys.created, sortable: true, value: (k) => new Date(k.createdAt).getTime() },
342
+ { key: "actions", label: "", class: "w-10" },
343
+ ];
344
+ let newKeyName = $state("");
345
+ let createdKey = $state<string | null>(null);
346
+ let keyBusy = $state(false);
347
+ async function loadApiKeys(): Promise<void> {
348
+ if (!caps.apiKey) return;
349
+ const { data: res } = await api.auth.apiKey.list();
350
+ // the endpoint returns { apiKeys: [...] }; tolerate a bare array too
351
+ apiKeys = (Array.isArray(res) ? res : ((res as { apiKeys?: ApiKey[] } | null)?.apiKeys ?? [])) as ApiKey[];
352
+ }
353
+ async function createApiKey(event: SubmitEvent): Promise<void> {
354
+ event.preventDefault();
355
+ keyBusy = true;
356
+ const { data: res, error } = await api.auth.apiKey.create({ name: newKeyName.trim() || undefined });
357
+ keyBusy = false;
358
+ if (error) return void toast.error(error.message ?? i18n.t.account.saveFailed);
359
+ createdKey = res?.key ?? null;
360
+ newKeyName = "";
361
+ await loadApiKeys();
362
+ }
363
+ async function deleteApiKey(keyId: string): Promise<void> {
364
+ keyBusy = true;
365
+ const { error } = await api.auth.apiKey.delete({ keyId });
366
+ keyBusy = false;
367
+ if (error) return void toast.error(error.message ?? i18n.t.apiKeys.revokeFailed);
368
+ toast.success(i18n.t.apiKeys.revoked);
369
+ await loadApiKeys();
370
+ }
371
+ async function copyKey(): Promise<void> {
372
+ if (createdKey) await navigator.clipboard.writeText(createdKey).catch(() => undefined);
373
+ toast.success(i18n.t.apiKeys.copied);
374
+ }
375
+
376
+ $effect(() => {
377
+ void loadSessions();
378
+ void loadAccounts();
379
+ void loadDeviceSessions();
380
+ void loadApiKeys();
381
+ void loadPasskeys();
382
+ });
383
+ </script>
384
+
385
+ <div class="flex flex-col gap-6">
386
+ <h1 class="text-2xl font-semibold">{i18n.t.account.title}</h1>
387
+
388
+ <div class="flex flex-col gap-6 lg:flex-row">
389
+ <nav class="flex shrink-0 flex-wrap gap-1 lg:w-48 lg:flex-col">
390
+ {#each navItems as key (key)}
391
+ <Button
392
+ variant="ghost"
393
+ onclick={() => (section = key)}
394
+ aria-current={section === key ? "page" : undefined}
395
+ class="h-auto justify-start px-3 py-2 text-sm font-medium {section === key ? 'bg-muted' : 'text-muted-foreground'}"
396
+ >
397
+ {i18n.t.account.nav[key]}
398
+ </Button>
399
+ {/each}
400
+ </nav>
401
+
402
+ <div class="min-w-0 flex-1">
403
+ {#if section === "profile"}
404
+ <Card.Root>
405
+ <Card.Header><Card.Title>{i18n.t.account.profile}</Card.Title></Card.Header>
406
+ <Card.Content>
407
+ <form class="flex max-w-md flex-col gap-4" onsubmit={saveProfile}>
408
+ <div class="flex flex-col gap-2">
409
+ <Label for="name">{i18n.t.account.name}</Label>
410
+ <Input id="name" bind:value={name} required />
411
+ </div>
412
+ {#if caps.username}
413
+ <div class="flex flex-col gap-2">
414
+ <Label for="username">{i18n.t.account.username}</Label>
415
+ <Input id="username" bind:value={username} autocomplete="username" />
416
+ </div>
417
+ {/if}
418
+ <div class="flex flex-col gap-2">
419
+ <Label for="email">{i18n.t.account.email}</Label>
420
+ <div class="flex items-center gap-2">
421
+ <Input id="email" type="email" bind:value={newEmail} required />
422
+ {#if caps.emailVerification}
423
+ <Badge variant={data.user.emailVerified ? "outline" : "secondary"} class={data.user.emailVerified ? "text-green-600 dark:text-green-400" : ""}>
424
+ {data.user.emailVerified ? i18n.t.account.verified : i18n.t.account.unverified}
425
+ </Badge>
426
+ {/if}
427
+ </div>
428
+ <div class="flex flex-wrap gap-2">
429
+ {#if emailChanged}
430
+ <Button type="button" variant="outline" size="sm" class="w-fit" disabled={changingEmail} onclick={changeEmail}>
431
+ {changingEmail ? i18n.t.account.saving : i18n.t.account.changeEmail}
432
+ </Button>
433
+ {/if}
434
+ {#if caps.emailVerification && !data.user.emailVerified}
435
+ <Button type="button" variant="outline" size="sm" class="w-fit" disabled={verifying} onclick={resendVerification}>
436
+ {verifying ? i18n.t.auth.sending : i18n.t.auth.resendVerification}
437
+ </Button>
438
+ {/if}
439
+ </div>
440
+ </div>
441
+ <div class="flex flex-col gap-2">
442
+ <Label>{i18n.t.account.role}</Label>
443
+ <div class="text-muted-foreground text-sm capitalize">{data.user.role ?? "user"}</div>
444
+ </div>
445
+ <Button type="submit" class="w-fit" disabled={savingProfile || !nameChanged}>{i18n.t.account.save}</Button>
446
+ </form>
447
+ {#if caps.phoneNumber}
448
+ <div class="mt-6 flex max-w-md flex-col gap-2 border-t pt-6">
449
+ <div class="flex items-center gap-2">
450
+ <Label for="phone">{i18n.t.account.phone}</Label>
451
+ {#if data.user.phoneNumberVerified}
452
+ <Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.account.verified}</Badge>
453
+ {/if}
454
+ </div>
455
+ <Input id="phone" type="tel" bind:value={phone} autocomplete="tel" />
456
+ {#if phoneOtpSent}
457
+ <Input placeholder={i18n.t.account.phoneCode} bind:value={phoneCode} inputmode="numeric" autocomplete="one-time-code" />
458
+ <Button type="button" size="sm" class="w-fit" disabled={phoneBusy || !phoneCode} onclick={verifyPhone}>{i18n.t.account.phoneVerify}</Button>
459
+ {:else}
460
+ <Button type="button" variant="outline" size="sm" class="w-fit" disabled={phoneBusy || !phone} onclick={sendPhoneOtp}>
461
+ {phoneBusy ? i18n.t.auth.sending : i18n.t.account.phoneSendCode}
462
+ </Button>
463
+ {/if}
464
+ </div>
465
+ {/if}
466
+ </Card.Content>
467
+ </Card.Root>
468
+ {:else if section === "security"}
469
+ <div class="flex flex-col gap-6">
470
+ <Card.Root>
471
+ <Card.Header><Card.Title>{i18n.t.account.changePassword}</Card.Title></Card.Header>
472
+ <Card.Content>
473
+ <form class="flex max-w-md flex-col gap-4" onsubmit={changePassword}>
474
+ <div class="flex flex-col gap-2">
475
+ <Label for="current">{i18n.t.account.currentPassword}</Label>
476
+ <Input id="current" type="password" bind:value={currentPassword} required autocomplete="current-password" />
477
+ </div>
478
+ <div class="flex flex-col gap-2">
479
+ <Label for="new">{i18n.t.account.newPassword}</Label>
480
+ <Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
481
+ </div>
482
+ <Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
483
+ </form>
484
+ </Card.Content>
485
+ </Card.Root>
486
+
487
+ {#if caps.twoFactor}
488
+ <Card.Root>
489
+ <Card.Header class="flex flex-row items-center justify-between gap-2">
490
+ <div>
491
+ <Card.Title>{i18n.t.account.twoFactorTitle}</Card.Title>
492
+ <p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.twoFactorDesc}</p>
493
+ </div>
494
+ <Badge variant={twoFaOn ? "default" : "secondary"}>{twoFaOn ? i18n.t.account.twoFactorOn : i18n.t.account.twoFactorOff}</Badge>
495
+ </Card.Header>
496
+ <Card.Content class="flex max-w-md flex-col gap-4">
497
+ {#if twoFaOn}
498
+ <div class="flex flex-col gap-2">
499
+ <Label for="tf-off-pw">{i18n.t.account.password}</Label>
500
+ <Input id="tf-off-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
501
+ </div>
502
+ <div class="flex flex-wrap gap-2">
503
+ <Button disabled={twoFaBusy || !twoFaPassword} onclick={regenerateBackupCodes}>{i18n.t.account.regenerateBackupCodes}</Button>
504
+ <Button variant="destructive" disabled={twoFaBusy || !twoFaPassword} onclick={disable2fa}>{i18n.t.account.disable}</Button>
505
+ </div>
506
+ <p class="text-muted-foreground text-xs">{i18n.t.account.regenerateBackupCodesHint}</p>
507
+ {#if regenCodes}
508
+ <div class="flex flex-col gap-1">
509
+ <Label>{i18n.t.account.backupCodes}</Label>
510
+ <p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
511
+ <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
512
+ {#each regenCodes as c (c)}<span>{c}</span>{/each}
513
+ </div>
514
+ <Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(regenCodes!)}>{i18n.t.account.downloadBackupCodes}</Button>
515
+ </div>
516
+ {/if}
517
+ {:else if setup}
518
+ <p class="text-muted-foreground text-sm">{i18n.t.account.scanHint}</p>
519
+ {#if qrDataUrl}
520
+ <img src={qrDataUrl} alt="TOTP QR code" width="176" height="176" class="rounded border bg-white p-2" />
521
+ {/if}
522
+ <div class="flex flex-col gap-1">
523
+ <Label>{i18n.t.account.setupKey}</Label>
524
+ <code class="bg-muted overflow-x-auto rounded px-2 py-1 text-xs">{setup.totpURI}</code>
525
+ </div>
526
+ <div class="flex flex-col gap-1">
527
+ <Label>{i18n.t.account.backupCodes}</Label>
528
+ <p class="text-muted-foreground text-xs">{i18n.t.account.backupCodesHint}</p>
529
+ <div class="bg-muted grid grid-cols-2 gap-1 rounded p-2 font-mono text-xs" data-testid="backup-codes">
530
+ {#each setup.backupCodes as c (c)}<span>{c}</span>{/each}
531
+ </div>
532
+ <Button variant="outline" size="sm" class="w-fit" onclick={() => downloadBackupCodes(setup!.backupCodes)}>{i18n.t.account.downloadBackupCodes}</Button>
533
+ </div>
534
+ <div class="flex flex-col gap-2">
535
+ <Label for="tf-code">{i18n.t.account.code}</Label>
536
+ <Input id="tf-code" bind:value={twoFaCode} inputmode="numeric" autocomplete="one-time-code" />
537
+ </div>
538
+ <Button class="w-fit" disabled={twoFaBusy || !twoFaCode} onclick={verify2fa}>{i18n.t.account.verify}</Button>
539
+ {:else}
540
+ <div class="flex flex-col gap-2">
541
+ <Label for="tf-on-pw">{i18n.t.account.password}</Label>
542
+ <Input id="tf-on-pw" type="password" bind:value={twoFaPassword} autocomplete="current-password" />
543
+ </div>
544
+ <Button class="w-fit" disabled={twoFaBusy || !twoFaPassword} onclick={enable2fa}>{i18n.t.account.enable}</Button>
545
+ {/if}
546
+ </Card.Content>
547
+ </Card.Root>
548
+ {/if}
549
+ {#if caps.passkey}
550
+ <Card.Root>
551
+ <Card.Header>
552
+ <Card.Title>{i18n.t.passkeys.title}</Card.Title>
553
+ <Card.Description>{i18n.t.passkeys.subtitle}</Card.Description>
554
+ </Card.Header>
555
+ <Card.Content class="flex flex-col gap-4">
556
+ <form class="flex items-end gap-2" onsubmit={registerPasskey}>
557
+ <div class="flex flex-1 flex-col gap-2">
558
+ <Label for="passkey-name">{i18n.t.passkeys.name}</Label>
559
+ <Input id="passkey-name" bind:value={newPasskeyName} placeholder={i18n.t.passkeys.namePlaceholder} />
560
+ </div>
561
+ <Button type="submit" disabled={passkeyBusy}>{i18n.t.passkeys.add}</Button>
562
+ </form>
563
+ <DataTable
564
+ columns={passkeysColumns}
565
+ rows={passkeys}
566
+ getKey={(pk) => pk.id}
567
+ bind:sort={passkeysSort}
568
+ bind:page={passkeysPage}
569
+ perPage={PAGE_SIZE}
570
+ label={fmt(i18n.t.passkeys.total, { count: passkeys.length })}
571
+ empty={i18n.t.passkeys.empty}
572
+ >
573
+ {#snippet row(pk)}
574
+ <Table.Cell class="font-medium">{pk.name || i18n.t.passkeys.untitled}</Table.Cell>
575
+ <Table.Cell class="text-muted-foreground">{formatDateTime(pk.createdAt)}</Table.Cell>
576
+ <Table.Cell>
577
+ <Button variant="ghost" size="sm" disabled={passkeyBusy} onclick={() => deletePasskey(pk.id)}>{i18n.t.passkeys.remove}</Button>
578
+ </Table.Cell>
579
+ {/snippet}
580
+ </DataTable>
581
+ </Card.Content>
582
+ </Card.Root>
583
+ {/if}
584
+ </div>
585
+ {:else if section === "connected"}
586
+ <Card.Root>
587
+ <Card.Header>
588
+ <Card.Title>{i18n.t.account.connectedTitle}</Card.Title>
589
+ <p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.connectedDesc}</p>
590
+ </Card.Header>
591
+ <Card.Content class="flex flex-col gap-3">
592
+ {#each caps.providers as provider (provider)}
593
+ {@const isLinked = linkedProviders.has(provider)}
594
+ <div class="flex items-center justify-between gap-4 rounded-md border p-3">
595
+ <div class="flex items-center gap-2">
596
+ <span class="font-medium capitalize">{provider}</span>
597
+ <Badge variant={isLinked ? "outline" : "secondary"}>{isLinked ? i18n.t.account.connected : i18n.t.account.notConnected}</Badge>
598
+ </div>
599
+ {#if isLinked}
600
+ <Button variant="outline" size="sm" onclick={() => disconnect(provider)}>{i18n.t.account.disconnect}</Button>
601
+ {:else}
602
+ <Button size="sm" onclick={() => connect(provider)}>{i18n.t.account.connect}</Button>
603
+ {/if}
604
+ </div>
605
+ {/each}
606
+ </Card.Content>
607
+ </Card.Root>
608
+ {:else if section === "sessions"}
609
+ {#if caps.multiSession && deviceSessions.filter((d) => d.user.id !== data.user.id).length}
610
+ <Card.Root>
611
+ <Card.Header><Card.Title>{i18n.t.sessions.accountsTitle}</Card.Title></Card.Header>
612
+ <Card.Content class="flex flex-col gap-2">
613
+ {#each deviceSessions.filter((d) => d.user.id !== data.user.id) as d (d.session.token)}
614
+ <div class="flex items-center justify-between gap-4 rounded-md border p-3">
615
+ <div>
616
+ <div class="font-medium">{d.user.name}</div>
617
+ <div class="text-muted-foreground text-xs">{d.user.email}</div>
618
+ </div>
619
+ <Button variant="outline" size="sm" disabled={busy} onclick={() => switchAccount(d.session.token)}>{i18n.t.sessions.switchAccount}</Button>
620
+ </div>
621
+ {/each}
622
+ </Card.Content>
623
+ </Card.Root>
624
+ {/if}
625
+ <Card.Root>
626
+ <Card.Header class="flex flex-row items-center justify-between gap-2">
627
+ <Card.Title>{i18n.t.sessions.title}</Card.Title>
628
+ <Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
629
+ </Card.Header>
630
+ <Card.Content>
631
+ <DataTable
632
+ columns={sessionsColumns}
633
+ rows={sessions}
634
+ getKey={(s) => s.id}
635
+ bind:sort={sessionsSort}
636
+ bind:page={sessionsPage}
637
+ perPage={PAGE_SIZE}
638
+ label={fmt(i18n.t.sessions.total, { count: sessions.length })}
639
+ empty={i18n.t.sessions.empty}
640
+ >
641
+ {#snippet row(s)}
642
+ {@const isCurrent = s.id === data.currentSessionId}
643
+ <Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
644
+ <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
645
+ <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
646
+ <Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
647
+ <Table.Cell>
648
+ <Button variant="ghost" size="sm" disabled={busy || isCurrent} onclick={() => revoke(s.token)}>{i18n.t.sessions.revoke}</Button>
649
+ </Table.Cell>
650
+ {/snippet}
651
+ </DataTable>
652
+ </Card.Content>
653
+ </Card.Root>
654
+ {:else if section === "apiKeys"}
655
+ <Card.Root>
656
+ <Card.Header>
657
+ <Card.Title>{i18n.t.apiKeys.title}</Card.Title>
658
+ <Card.Description>{i18n.t.apiKeys.subtitle}</Card.Description>
659
+ </Card.Header>
660
+ <Card.Content class="flex flex-col gap-4">
661
+ <form class="flex items-end gap-2" onsubmit={createApiKey}>
662
+ <div class="flex flex-1 flex-col gap-2">
663
+ <Label for="key-name">{i18n.t.apiKeys.name}</Label>
664
+ <Input id="key-name" bind:value={newKeyName} placeholder={i18n.t.apiKeys.namePlaceholder} />
665
+ </div>
666
+ <Button type="submit" disabled={keyBusy}>{i18n.t.apiKeys.create}</Button>
667
+ </form>
668
+ <DataTable
669
+ columns={apiKeysColumns}
670
+ rows={apiKeys}
671
+ getKey={(k) => k.id}
672
+ bind:sort={apiKeysSort}
673
+ bind:page={apiKeysPage}
674
+ perPage={PAGE_SIZE}
675
+ label={fmt(i18n.t.apiKeys.total, { count: apiKeys.length })}
676
+ empty={i18n.t.apiKeys.empty}
677
+ >
678
+ {#snippet row(k)}
679
+ <Table.Cell class="font-medium">{k.name || i18n.t.apiKeys.untitled}</Table.Cell>
680
+ <Table.Cell class="text-muted-foreground font-mono text-xs">{k.start ? `${k.start}…` : "—"}</Table.Cell>
681
+ <Table.Cell class="text-muted-foreground">{formatDateTime(k.createdAt)}</Table.Cell>
682
+ <Table.Cell>
683
+ <Button variant="ghost" size="sm" disabled={keyBusy} onclick={() => deleteApiKey(k.id)}>{i18n.t.apiKeys.revoke}</Button>
684
+ </Table.Cell>
685
+ {/snippet}
686
+ </DataTable>
687
+ </Card.Content>
688
+ </Card.Root>
689
+ <Dialog.Root open={createdKey !== null} onOpenChange={(v) => { if (!v) createdKey = null; }}>
690
+ <Dialog.Content>
691
+ <Dialog.Header>
692
+ <Dialog.Title>{i18n.t.apiKeys.createdTitle}</Dialog.Title>
693
+ <Dialog.Description>{i18n.t.apiKeys.createdDesc}</Dialog.Description>
694
+ </Dialog.Header>
695
+ <div class="bg-muted rounded-md p-3 font-mono text-sm break-all">{createdKey}</div>
696
+ <Dialog.Footer>
697
+ <Button variant="outline" onclick={copyKey}>{i18n.t.apiKeys.copy}</Button>
698
+ <Button onclick={() => (createdKey = null)}>{i18n.t.apiKeys.done}</Button>
699
+ </Dialog.Footer>
700
+ </Dialog.Content>
701
+ </Dialog.Root>
702
+ {:else if section === "danger"}
703
+ <Card.Root class="border-destructive/50">
704
+ <Card.Header>
705
+ <Card.Title class="text-destructive">{i18n.t.account.dangerTitle}</Card.Title>
706
+ <p class="text-muted-foreground mt-1 text-sm">{i18n.t.account.dangerDesc}</p>
707
+ </Card.Header>
708
+ <Card.Content>
709
+ <Button variant="destructive" onclick={() => { deletePassword = ""; deleteOpen = true; }}>{i18n.t.account.deleteAccount}</Button>
710
+ </Card.Content>
711
+ </Card.Root>
712
+ {/if}
713
+ </div>
714
+ </div>
715
+ </div>
716
+
717
+ <Dialog.Root bind:open={deleteOpen}>
718
+ <Dialog.Content>
719
+ <Dialog.Header><Dialog.Title>{i18n.t.account.deleteAccount}</Dialog.Title></Dialog.Header>
720
+ <p class="text-muted-foreground text-sm">{i18n.t.account.deleteAccountConfirm}</p>
721
+ <div class="flex flex-col gap-2">
722
+ <Label for="del-pw">{i18n.t.account.password}</Label>
723
+ <Input id="del-pw" type="password" bind:value={deletePassword} autocomplete="current-password" />
724
+ </div>
725
+ <Dialog.Footer>
726
+ <Button variant="outline" onclick={() => (deleteOpen = false)}>{i18n.t.users.cancel}</Button>
727
+ <Button variant="destructive" disabled={deleteBusy || !deletePassword} onclick={deleteAccount}>{i18n.t.account.deleteAccount}</Button>
728
+ </Dialog.Footer>
729
+ </Dialog.Content>
730
+ </Dialog.Root>