@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.
Files changed (141) hide show
  1. package/README.md +2 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -8,7 +8,9 @@
8
8
  import * as Table from "$lib/components/ui/table";
9
9
  import * as Dialog from "$lib/components/ui/dialog";
10
10
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
11
- import TablePagination from "$lib/components/table-pagination.svelte";
11
+ import * as Select from "$lib/components/ui/select";
12
+ import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
13
+ import TableToolbar, { type ToolbarFilter, type ToolbarSearchField } from "$lib/components/table-toolbar.svelte";
12
14
  import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
13
15
  import PlusIcon from "@lucide/svelte/icons/plus";
14
16
  import { toast } from "svelte-sonner";
@@ -18,27 +20,94 @@
18
20
 
19
21
  let { data }: { data: PageData } = $props();
20
22
  const i18n = getI18n();
23
+ const emailVerificationEnabled = $derived(data.capabilities.emailVerification);
24
+ // Assignable roles come from the server (access-control); label the known ones.
25
+ const roleNames = $derived(data.capabilities.roles ?? ["admin", "user"]);
26
+ const roleLabel = (r: string): string =>
27
+ ({ admin: i18n.t.users.roleAdmin, user: i18n.t.users.roleUser, moderator: i18n.t.users.roleModerator }) [r] ?? r;
21
28
 
22
- type Row = { id: string; name: string; email: string; role?: string | null; banned?: boolean | null };
29
+ type Row = {
30
+ id: string;
31
+ name: string;
32
+ email: string;
33
+ role?: string | null;
34
+ banned?: boolean | null;
35
+ emailVerified?: boolean | null;
36
+ createdAt?: string | Date | null;
37
+ banReason?: string | null;
38
+ banExpires?: string | Date | null;
39
+ };
23
40
 
24
- const PAGE_SIZE = 5;
41
+ const PAGE_SIZE = DEFAULT_PAGE_SIZE;
42
+ const LOAD_LIMIT = 500; // bounded client-side list; add server-side filtering for larger deployments
25
43
  let users = $state<Row[]>([]);
26
- let total = $state(0);
27
44
  let page = $state(1);
28
- let search = $state("");
45
+ let sort = $state<SortState | null>(null);
29
46
  let busy = $state(false);
30
47
 
48
+ // Toolbar: filters + search both commit on the Search button (nothing applies
49
+ // until then), so multiple filter/search conditions apply together.
50
+ let search = $state("");
51
+ let appliedSearch = $state("");
52
+ let searchField = $state("email");
53
+ let appliedSearchField = $state("email");
54
+ let filterValues = $state<Record<string, string>>({ role: "", status: "" });
55
+ let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
56
+
57
+ const columns: DataTableColumn<Row>[] = [
58
+ { key: "name", label: i18n.t.users.name, sortable: true },
59
+ { key: "email", label: i18n.t.users.email, sortable: true },
60
+ { key: "role", label: i18n.t.users.role, sortable: true },
61
+ { key: "status", label: i18n.t.users.status },
62
+ { key: "createdAt", label: i18n.t.users.joined, sortable: true, value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0) },
63
+ { key: "actions", label: "", class: "w-10" },
64
+ ];
65
+ const filters = $derived<ToolbarFilter[]>([
66
+ {
67
+ key: "role",
68
+ label: i18n.t.users.role,
69
+ options: [{ value: "", label: i18n.t.toolbar.all }, ...roleNames.map((r) => ({ value: r, label: roleLabel(r) }))],
70
+ },
71
+ {
72
+ key: "status",
73
+ label: i18n.t.users.status,
74
+ options: [
75
+ { value: "", label: i18n.t.toolbar.all },
76
+ { value: "active", label: i18n.t.users.active },
77
+ { value: "banned", label: i18n.t.users.banned },
78
+ ],
79
+ },
80
+ ]);
81
+ const searchFields: ToolbarSearchField[] = [
82
+ { value: "email", label: i18n.t.users.email },
83
+ { value: "name", label: i18n.t.users.name },
84
+ ];
85
+
86
+ const filtered = $derived(
87
+ users.filter((u) => {
88
+ if (appliedSearch) {
89
+ const hay = (appliedSearchField === "name" ? u.name : u.email).toLowerCase();
90
+ if (!hay.includes(appliedSearch.toLowerCase())) return false;
91
+ }
92
+ if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
93
+ if (appliedFilters.status === "banned" && !u.banned) return false;
94
+ if (appliedFilters.status === "active" && u.banned) return false;
95
+ return true;
96
+ }),
97
+ );
98
+
31
99
  async function load(): Promise<void> {
32
- const { data: res, error } = await api.auth.admin.listUsers({
33
- query: {
34
- limit: PAGE_SIZE,
35
- offset: (page - 1) * PAGE_SIZE,
36
- ...(search ? { searchField: "email", searchOperator: "contains", searchValue: search } : {}),
37
- },
38
- });
100
+ const { data: res, error } = await api.auth.admin.listUsers({ query: { limit: LOAD_LIMIT } });
39
101
  if (error) return void toast.error(error.message ?? i18n.t.users.loadFailed);
40
102
  users = (res?.users ?? []) as Row[];
41
- total = res?.total ?? users.length;
103
+ }
104
+
105
+ async function applySearch(): Promise<void> {
106
+ await load(); // refresh from the server on explicit search, then filter client-side
107
+ appliedSearch = search;
108
+ appliedSearchField = searchField;
109
+ appliedFilters = { ...filterValues };
110
+ page = 1;
42
111
  }
43
112
 
44
113
  // Impersonate — become the user, then a banner offers "stop impersonating".
@@ -50,7 +119,7 @@
50
119
 
51
120
  // Create user
52
121
  let createOpen = $state(false);
53
- let form = $state({ name: "", email: "", password: "", confirm: "", admin: false });
122
+ let form = $state({ name: "", email: "", password: "", confirm: "", role: "user", sendVerify: true });
54
123
  let createError = $state("");
55
124
  async function createUser(event: Event): Promise<void> {
56
125
  event.preventDefault();
@@ -64,13 +133,18 @@
64
133
  name: form.name,
65
134
  email: form.email,
66
135
  password: form.password,
67
- role: form.admin ? "admin" : "user",
136
+ // Roles are server-driven (capabilities.roles); the typed client only knows
137
+ // the built-in ones, so assert at this boundary — the server validates it.
138
+ role: form.role as "admin" | "user",
68
139
  });
69
140
  busy = false;
70
141
  if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
71
142
  toast.success(i18n.t.users.userCreated);
143
+ if (form.sendVerify && emailVerificationEnabled) {
144
+ await api.auth.sendVerificationEmail({ email: form.email, callbackURL: `${location.origin}/admin` });
145
+ }
72
146
  createOpen = false;
73
- form = { name: "", email: "", password: "", confirm: "", admin: false };
147
+ form = { name: "", email: "", password: "", confirm: "", role: "user", sendVerify: true };
74
148
  page = 1;
75
149
  await load();
76
150
  }
@@ -85,7 +159,7 @@
85
159
  // profile
86
160
  let mName = $state("");
87
161
  let mEmail = $state("");
88
- let mAdmin = $state(false);
162
+ let mRole = $state("user");
89
163
  // security
90
164
  let mNewPassword = $state("");
91
165
  let mConfirmPassword = $state("");
@@ -95,7 +169,13 @@
95
169
  // sessions
96
170
  let mSessions = $state<Session[]>([]);
97
171
  let mSessionsPage = $state(1);
98
- const mPagedSessions = $derived(mSessions.slice((mSessionsPage - 1) * PAGE_SIZE, mSessionsPage * PAGE_SIZE));
172
+ let mSessionsSort = $state<SortState | null>(null);
173
+ const mSessionsColumns: DataTableColumn<Session>[] = [
174
+ { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true },
175
+ { key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
176
+ { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
177
+ { key: "actions", label: "", class: "w-10" },
178
+ ];
99
179
  // danger
100
180
  let mDeleteArmed = $state(false);
101
181
 
@@ -104,7 +184,7 @@
104
184
  mSection = "profile";
105
185
  mName = u.name;
106
186
  mEmail = u.email;
107
- mAdmin = u.role === "admin";
187
+ mRole = u.role ?? "user";
108
188
  mNewPassword = "";
109
189
  mConfirmPassword = "";
110
190
  mPwError = "";
@@ -127,13 +207,25 @@
127
207
  event.preventDefault();
128
208
  if (!mUser) return;
129
209
  busy = true;
210
+ const nextRole = mRole;
211
+ // Profile fields go through updateUser; role changes go through the dedicated
212
+ // setRole endpoint (aligns with the admin plugin's access-control model).
130
213
  const { error } = await api.auth.admin.updateUser({
131
214
  userId: mUser.id,
132
- data: { name: mName, email: mEmail, role: mAdmin ? "admin" : "user" },
215
+ data: { name: mName, email: mEmail },
133
216
  });
217
+ if (!error && nextRole !== (mUser.role ?? "user")) {
218
+ // Custom roles are valid at runtime (server-enforced); assert past the
219
+ // typed client, which only knows the built-in roles.
220
+ const { error: roleError } = await api.auth.admin.setRole({ userId: mUser.id, role: nextRole as "admin" | "user" });
221
+ if (roleError) {
222
+ busy = false;
223
+ return void toast.error(roleError.message ?? i18n.t.users.actionFailed);
224
+ }
225
+ }
134
226
  busy = false;
135
227
  if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
136
- mUser = { ...mUser, name: mName, email: mEmail, role: mAdmin ? "admin" : "user" };
228
+ mUser = { ...mUser, name: mName, email: mEmail, role: nextRole };
137
229
  toast.success(i18n.t.users.userUpdated);
138
230
  await load();
139
231
  }
@@ -155,6 +247,35 @@
155
247
  toast.success(i18n.t.users.passwordSet);
156
248
  }
157
249
 
250
+ async function resendVerification(): Promise<void> {
251
+ if (!mUser) return;
252
+ busy = true;
253
+ const { error } = await api.auth.sendVerificationEmail({ email: mUser.email, callbackURL: `${location.origin}/admin` });
254
+ busy = false;
255
+ if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
256
+ toast.success(i18n.t.users.verificationSent);
257
+ }
258
+
259
+ async function markVerified(): Promise<void> {
260
+ if (!mUser) return;
261
+ busy = true;
262
+ const { error } = await api.auth.admin.updateUser({ userId: mUser.id, data: { emailVerified: true } });
263
+ busy = false;
264
+ if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
265
+ mUser = { ...mUser, emailVerified: true };
266
+ toast.success(i18n.t.users.markedVerified);
267
+ await load();
268
+ }
269
+
270
+ async function sendResetEmail(): Promise<void> {
271
+ if (!mUser) return;
272
+ busy = true;
273
+ const { error } = await api.auth.requestPasswordReset({ email: mUser.email, redirectTo: `${location.origin}/reset-password` });
274
+ busy = false;
275
+ if (error) return void toast.error(error.message ?? i18n.t.users.actionFailed);
276
+ toast.success(i18n.t.users.resetEmailSent);
277
+ }
278
+
158
279
  async function ban(event: Event): Promise<void> {
159
280
  event.preventDefault();
160
281
  if (!mUser) return;
@@ -213,12 +334,6 @@
213
334
  await load();
214
335
  }
215
336
 
216
- function searchInput(event: Event): void {
217
- search = (event.target as HTMLInputElement).value;
218
- page = 1;
219
- void load();
220
- }
221
-
222
337
  $effect(() => {
223
338
  void load();
224
339
  });
@@ -229,58 +344,58 @@
229
344
  <h1 class="text-2xl font-semibold">{i18n.t.users.title}</h1>
230
345
  <Button onclick={() => { createError = ""; createOpen = true; }}><PlusIcon class="size-4" />{i18n.t.users.addUser}</Button>
231
346
  </div>
232
- <Input placeholder={i18n.t.users.search} value={search} oninput={searchInput} class="max-w-xs" />
233
-
234
- <div class="rounded-md border">
235
- <Table.Root>
236
- <Table.Header>
237
- <Table.Row>
238
- <Table.Head>{i18n.t.users.name}</Table.Head>
239
- <Table.Head>{i18n.t.users.email}</Table.Head>
240
- <Table.Head>{i18n.t.users.role}</Table.Head>
241
- <Table.Head>{i18n.t.users.status}</Table.Head>
242
- <Table.Head class="w-10"></Table.Head>
243
- </Table.Row>
244
- </Table.Header>
245
- <Table.Body>
246
- {#each users as user (user.id)}
247
- <Table.Row>
248
- <Table.Cell class="font-medium">{user.name}</Table.Cell>
249
- <Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
250
- <Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
251
- <Table.Cell>
252
- {#if user.banned}<Badge variant="destructive">{i18n.t.users.banned}</Badge>{:else}<Badge variant="outline">{i18n.t.users.active}</Badge>{/if}
253
- </Table.Cell>
254
- <Table.Cell>
255
- <DropdownMenu.Root>
256
- <DropdownMenu.Trigger>
257
- {#snippet child({ props })}
258
- <Button {...props} variant="ghost" size="icon"><EllipsisIcon class="size-4" /></Button>
259
- {/snippet}
260
- </DropdownMenu.Trigger>
261
- <DropdownMenu.Content align="end">
262
- <DropdownMenu.Item onSelect={() => openManage(user)}>{i18n.t.users.manage}</DropdownMenu.Item>
263
- <DropdownMenu.Item disabled={user.id === data.currentUserId} onSelect={() => impersonate(user)}>
264
- {i18n.t.users.impersonate}
265
- </DropdownMenu.Item>
266
- </DropdownMenu.Content>
267
- </DropdownMenu.Root>
268
- </Table.Cell>
269
- </Table.Row>
270
- {:else}
271
- <Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{i18n.t.users.empty}</Table.Cell></Table.Row>
272
- {/each}
273
- </Table.Body>
274
- </Table.Root>
275
- </div>
347
+ <TableToolbar
348
+ {filters}
349
+ bind:filterValues
350
+ {searchFields}
351
+ bind:searchField
352
+ bind:search
353
+ filterHeading={i18n.t.toolbar.filter}
354
+ searchHeading={i18n.t.toolbar.search}
355
+ searchButton={i18n.t.toolbar.searchButton}
356
+ onSearch={applySearch}
357
+ />
276
358
 
277
- <TablePagination
278
- count={total}
279
- perPage={PAGE_SIZE}
359
+ <DataTable
360
+ {columns}
361
+ rows={filtered}
362
+ getKey={(u) => u.id}
363
+ empty={i18n.t.users.empty}
364
+ bind:sort
280
365
  bind:page
281
- label={fmt(i18n.t.users.total, { count: total })}
282
- onPageChange={() => void load()}
283
- />
366
+ perPage={PAGE_SIZE}
367
+ label={fmt(i18n.t.users.total, { count: filtered.length })}
368
+ >
369
+ {#snippet row(user)}
370
+ <Table.Cell class="font-medium">{user.name}</Table.Cell>
371
+ <Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
372
+ <Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
373
+ <Table.Cell>
374
+ <div class="flex flex-wrap gap-1">
375
+ {#if user.banned}<Badge variant="destructive">{i18n.t.users.banned}</Badge>{:else}<Badge variant="outline">{i18n.t.users.active}</Badge>{/if}
376
+ {#if emailVerificationEnabled}
377
+ {#if user.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>{:else}<Badge variant="secondary">{i18n.t.users.unverified}</Badge>{/if}
378
+ {/if}
379
+ </div>
380
+ </Table.Cell>
381
+ <Table.Cell class="text-muted-foreground">{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
382
+ <Table.Cell>
383
+ <DropdownMenu.Root>
384
+ <DropdownMenu.Trigger>
385
+ {#snippet child({ props })}
386
+ <Button {...props} variant="ghost" size="icon"><EllipsisIcon class="size-4" /></Button>
387
+ {/snippet}
388
+ </DropdownMenu.Trigger>
389
+ <DropdownMenu.Content align="end">
390
+ <DropdownMenu.Item onSelect={() => openManage(user)}>{i18n.t.users.manage}</DropdownMenu.Item>
391
+ <DropdownMenu.Item disabled={user.id === data.currentUserId} onSelect={() => impersonate(user)}>
392
+ {i18n.t.users.impersonate}
393
+ </DropdownMenu.Item>
394
+ </DropdownMenu.Content>
395
+ </DropdownMenu.Root>
396
+ </Table.Cell>
397
+ {/snippet}
398
+ </DataTable>
284
399
  </div>
285
400
 
286
401
  <!-- Create user -->
@@ -293,7 +408,18 @@
293
408
  <div class="flex flex-col gap-1"><Label for="c-password">{i18n.t.users.password}</Label><Input id="c-password" type="password" bind:value={form.password} required /></div>
294
409
  <div class="flex flex-col gap-1"><Label for="c-confirm">{i18n.t.users.confirmPassword}</Label><Input id="c-confirm" type="password" bind:value={form.confirm} required /></div>
295
410
  {#if createError}<p class="text-destructive text-sm">{createError}</p>{/if}
296
- <Label class="flex items-center gap-2"><Checkbox bind:checked={form.admin} />{i18n.t.users.makeAdmin}</Label>
411
+ <div class="flex flex-col gap-1">
412
+ <Label for="c-role">{i18n.t.users.role}</Label>
413
+ <Select.Root type="single" bind:value={form.role}>
414
+ <Select.Trigger id="c-role" class="w-full">{roleLabel(form.role)}</Select.Trigger>
415
+ <Select.Content>
416
+ {#each roleNames as r (r)}<Select.Item value={r}>{roleLabel(r)}</Select.Item>{/each}
417
+ </Select.Content>
418
+ </Select.Root>
419
+ </div>
420
+ {#if emailVerificationEnabled}
421
+ <Label class="flex items-center gap-2"><Checkbox bind:checked={form.sendVerify} />{i18n.t.users.sendVerificationOnCreate}</Label>
422
+ {/if}
297
423
  <Dialog.Footer>
298
424
  <Button type="button" variant="outline" onclick={() => (createOpen = false)}>{i18n.t.users.cancel}</Button>
299
425
  <Button type="submit" disabled={busy}>{i18n.t.users.create}</Button>
@@ -309,14 +435,14 @@
309
435
  <div class="flex flex-col gap-4 sm:flex-row">
310
436
  <nav class="flex shrink-0 flex-wrap gap-1 sm:w-40 sm:flex-col">
311
437
  {#each sections as key (key)}
312
- <button
313
- type="button"
438
+ <Button
439
+ variant="ghost"
314
440
  onclick={() => (mSection = key)}
315
441
  aria-current={mSection === key ? "page" : undefined}
316
- class="hover:bg-muted rounded-md px-3 py-2 text-left text-sm font-medium transition-colors {mSection === key ? 'bg-muted' : 'text-muted-foreground'}"
442
+ class="h-auto justify-start px-3 py-2 text-sm font-medium {mSection === key ? 'bg-muted' : 'text-muted-foreground'}"
317
443
  >
318
444
  {i18n.t.account.nav[key]}
319
- </button>
445
+ </Button>
320
446
  {/each}
321
447
  </nav>
322
448
 
@@ -325,8 +451,26 @@
325
451
  <form class="flex flex-col gap-3" onsubmit={saveProfile}>
326
452
  <div class="flex flex-col gap-1"><Label for="m-name">{i18n.t.users.name}</Label><Input id="m-name" bind:value={mName} required /></div>
327
453
  <div class="flex flex-col gap-1"><Label for="m-email">{i18n.t.users.email}</Label><Input id="m-email" type="email" bind:value={mEmail} required /></div>
328
- <Label class="flex items-center gap-2"><Checkbox bind:checked={mAdmin} />{i18n.t.users.makeAdmin}</Label>
454
+ <div class="flex flex-col gap-1">
455
+ <Label for="m-role">{i18n.t.users.role}</Label>
456
+ <Select.Root type="single" bind:value={mRole}>
457
+ <Select.Trigger id="m-role" class="w-full">{roleLabel(mRole)}</Select.Trigger>
458
+ <Select.Content>
459
+ {#each roleNames as r (r)}<Select.Item value={r}>{roleLabel(r)}</Select.Item>{/each}
460
+ </Select.Content>
461
+ </Select.Root>
462
+ </div>
329
463
  {#if mUser?.banned}<Badge variant="destructive" class="w-fit">{i18n.t.users.banned}</Badge>{/if}
464
+ {#if emailVerificationEnabled}
465
+ <div class="flex items-center gap-2">
466
+ {#if mUser?.emailVerified}<Badge variant="outline" class="text-green-600 dark:text-green-400">{i18n.t.users.verified}</Badge>
467
+ {:else}
468
+ <Badge variant="secondary">{i18n.t.users.unverified}</Badge>
469
+ <Button type="button" variant="outline" size="sm" disabled={busy} onclick={resendVerification}>{i18n.t.users.sendVerification}</Button>
470
+ <Button type="button" variant="outline" size="sm" disabled={busy} onclick={markVerified}>{i18n.t.users.markVerified}</Button>
471
+ {/if}
472
+ </div>
473
+ {/if}
330
474
  <Button type="submit" class="w-fit" disabled={busy}>{i18n.t.users.save}</Button>
331
475
  </form>
332
476
  {:else if mSection === "security"}
@@ -337,8 +481,15 @@
337
481
  {#if mPwError}<p class="text-destructive text-sm">{mPwError}</p>{/if}
338
482
  <Button type="submit" class="w-fit" disabled={busy}>{i18n.t.users.setPassword}</Button>
339
483
  </form>
484
+ <div class="border-t pt-4">
485
+ <Button type="button" variant="outline" disabled={busy} onclick={sendResetEmail}>{i18n.t.users.sendResetEmail}</Button>
486
+ </div>
340
487
  <div class="border-t pt-4">
341
488
  {#if mUser?.banned}
489
+ <div class="mb-3 flex flex-col gap-1 text-sm">
490
+ {#if mUser.banReason}<p><span class="text-muted-foreground">{i18n.t.users.banReason}:</span> {mUser.banReason}</p>{/if}
491
+ {#if mUser.banExpires}<p><span class="text-muted-foreground">{i18n.t.users.banExpiry}:</span> {formatDateTime(mUser.banExpires)}</p>{:else}<p class="text-muted-foreground">{i18n.t.users.banPermanent}</p>{/if}
492
+ </div>
342
493
  <Button variant="outline" disabled={busy} onclick={unban}>{i18n.t.users.unban}</Button>
343
494
  {:else}
344
495
  <form class="flex flex-col gap-3" onsubmit={ban}>
@@ -354,31 +505,23 @@
354
505
  <div class="flex justify-end">
355
506
  <Button variant="outline" size="sm" disabled={busy || mSessions.length === 0} onclick={revokeAllSessions}>{i18n.t.adminSessions.revokeAll}</Button>
356
507
  </div>
357
- <div class="rounded-md border">
358
- <Table.Root>
359
- <Table.Header>
360
- <Table.Row>
361
- <Table.Head>{i18n.t.adminSessions.device}</Table.Head>
362
- <Table.Head>{i18n.t.adminSessions.ip}</Table.Head>
363
- <Table.Head>{i18n.t.adminSessions.since}</Table.Head>
364
- <Table.Head class="w-10"></Table.Head>
365
- </Table.Row>
366
- </Table.Header>
367
- <Table.Body>
368
- {#each mPagedSessions as s (s.id)}
369
- <Table.Row>
370
- <Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
371
- <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
372
- <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
373
- <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
374
- </Table.Row>
375
- {:else}
376
- <Table.Row><Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">{i18n.t.adminSessions.empty}</Table.Cell></Table.Row>
377
- {/each}
378
- </Table.Body>
379
- </Table.Root>
380
- </div>
381
- <TablePagination count={mSessions.length} perPage={PAGE_SIZE} bind:page={mSessionsPage} />
508
+ <DataTable
509
+ columns={mSessionsColumns}
510
+ rows={mSessions}
511
+ getKey={(s) => s.id}
512
+ bind:sort={mSessionsSort}
513
+ bind:page={mSessionsPage}
514
+ perPage={PAGE_SIZE}
515
+ label={fmt(i18n.t.adminSessions.total, { count: mSessions.length })}
516
+ empty={i18n.t.adminSessions.empty}
517
+ >
518
+ {#snippet row(s)}
519
+ <Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
520
+ <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
521
+ <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
522
+ <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
523
+ {/snippet}
524
+ </DataTable>
382
525
  </div>
383
526
  {:else if mSection === "danger"}
384
527
  <div class="border-destructive/50 flex flex-col gap-3 rounded-md border p-4">
@@ -402,5 +545,8 @@
402
545
  {/if}
403
546
  </div>
404
547
  </div>
548
+ <Dialog.Footer>
549
+ <Button variant="outline" onclick={() => (manageOpen = false)}>{i18n.t.users.close}</Button>
550
+ </Dialog.Footer>
405
551
  </Dialog.Content>
406
552
  </Dialog.Root>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { page } from "$app/state";
2
3
  import { Button } from "$lib/components/ui/button";
3
4
  import { Input } from "$lib/components/ui/input";
4
5
  import { Label } from "$lib/components/ui/label";
@@ -17,7 +18,13 @@
17
18
  event.preventDefault();
18
19
  loading = true;
19
20
  error = null;
20
- const { error: authError } = await api.auth.requestPasswordReset({ email, redirectTo: "/reset-password" });
21
+ // Absolute URL: the emailed link hits the API to validate, then redirects
22
+ // here (the web app) with a valid token — a relative path would resolve
23
+ // against the API origin.
24
+ const { error: authError } = await api.auth.requestPasswordReset({
25
+ email,
26
+ redirectTo: `${page.url.origin}/reset-password`,
27
+ });
21
28
  loading = false;
22
29
  if (authError) {
23
30
  error = authError.message ?? i18n.t.auth.requestFailed;
@@ -0,0 +1,14 @@
1
+ import type { PageServerLoad } from "./$types";
2
+ import type { Capabilities } from "@podosoft/podokit-api-client";
3
+
4
+ // Capabilities is public so the login page can offer whichever sign-in methods
5
+ // (magic link, ...) the server actually enabled.
6
+ export const load: PageServerLoad = async ({ fetch }) => {
7
+ try {
8
+ const res = await fetch("/api/account/capabilities");
9
+ if (res.ok) return { capabilities: (await res.json()) as Capabilities };
10
+ } catch {
11
+ /* fall through to defaults */
12
+ }
13
+ return { capabilities: null };
14
+ };