@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  import { goto } from "$app/navigation";
3
4
  import { Button } from "#lib/components/ui/button/index.js";
4
5
  import { Input } from "#lib/components/ui/input/index.js";
@@ -15,6 +16,8 @@
15
16
  import PlusIcon from "@lucide/svelte/icons/plus";
16
17
  import { toast } from "svelte-sonner";
17
18
  import { api } from "#lib/api.js";
19
+ import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
20
+ import { DEFAULT_USER_SORT, adminUsersQuery, listAdminUsers } from "#lib/admin/users.js";
18
21
  import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
19
22
  import { cn } from "#lib/utils.js";
20
23
  import { deviceLabel } from "#lib/device-label.js";
@@ -42,11 +45,16 @@
42
45
  };
43
46
 
44
47
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
45
- const LOAD_LIMIT = 500; // bounded client-side list; add server-side filtering for larger deployments
48
+ // Sessions shown in the manage dialog: the newest page for one user.
49
+ const MANAGED_SESSIONS_LIMIT = 100;
46
50
  let users = $state<Row[]>([]);
51
+ let total = $state(0);
47
52
  let page = $state(1);
48
- let sort = $state<SortState | null>(null);
53
+ let sort = $state<SortState | null>({ ...DEFAULT_USER_SORT });
54
+ let loading = $state(false);
49
55
  let busy = $state(false);
56
+ let latestRequest = 0;
57
+ let loadedSortKey = `${DEFAULT_USER_SORT.key}:${DEFAULT_USER_SORT.dir}`;
50
58
 
51
59
  // Toolbar: filters + search both commit on the Search button (nothing applies
52
60
  // until then), so multiple filter/search conditions apply together.
@@ -93,32 +101,50 @@
93
101
  { value: "name", label: i18n.t.users.name },
94
102
  ]);
95
103
 
96
- const filtered = $derived(
97
- users.filter((u) => {
98
- if (appliedSearch) {
99
- const hay = (appliedSearchField === "name" ? u.name : u.email).toLowerCase();
100
- if (!hay.includes(appliedSearch.toLowerCase())) return false;
101
- }
102
- if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
103
- if (appliedFilters.status === "banned" && !u.banned) return false;
104
- if (appliedFilters.status === "pending" && u.signupApproved !== false) return false;
105
- if (appliedFilters.status === "active" && (u.banned || u.signupApproved === false)) return false;
106
- return true;
107
- }),
108
- );
109
-
104
+ // Loads only the current page; a newer request supersedes one still in flight.
110
105
  async function load(): Promise<void> {
111
- const { data: res, error } = await api.auth.admin.listUsers({ query: { limit: LOAD_LIMIT } });
112
- if (error) return void toast.error(error.message ?? i18n.t.users.loadFailed);
113
- users = (res?.users ?? []) as Row[];
106
+ const request = ++latestRequest;
107
+ const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
108
+ if (sortKey !== loadedSortKey) page = 1;
109
+ const query = adminUsersQuery({
110
+ page,
111
+ perPage: PAGE_SIZE,
112
+ sort,
113
+ search: appliedSearch,
114
+ searchField: appliedSearchField,
115
+ role: appliedFilters.role ?? "",
116
+ status: appliedFilters.status ?? "",
117
+ });
118
+ loading = true;
119
+ let result: { items: Row[]; total: number };
120
+ try {
121
+ result = await listAdminUsers(api, query);
122
+ } catch {
123
+ if (request !== latestRequest) return;
124
+ loading = false;
125
+ return void toast.error(i18n.t.users.loadFailed);
126
+ }
127
+ if (request !== latestRequest) return;
128
+ loading = false;
129
+ const rows = result.items;
130
+ const count = result.total;
131
+ // An action can empty the last page; show the one before it instead.
132
+ const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
133
+ if (rows.length === 0 && page > lastPage) {
134
+ page = lastPage;
135
+ return load();
136
+ }
137
+ loadedSortKey = sortKey;
138
+ users = rows;
139
+ total = count;
114
140
  }
115
141
 
116
- async function applySearch(): Promise<void> {
117
- await load(); // refresh from the server on explicit search, then filter client-side
142
+ function applySearch(): void {
118
143
  appliedSearch = search;
119
144
  appliedSearchField = searchField;
120
145
  appliedFilters = { ...filterValues };
121
146
  page = 1;
147
+ void load();
122
148
  }
123
149
 
124
150
  // Impersonate — become the user, then continue on the signed-in account page
@@ -176,7 +202,6 @@
176
202
 
177
203
  // Manage user — a two-pane modal mirroring the account settings, admin-scoped.
178
204
  type ManageSection = "profile" | "security" | "sessions" | "danger";
179
- type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
180
205
  let manageOpen = $state(false);
181
206
  let mUser = $state<Row | null>(null);
182
207
  let mSection = $state<ManageSection>("profile");
@@ -192,10 +217,11 @@
192
217
  let mBanReason = $state("");
193
218
  let mBanDays = $state("");
194
219
  // sessions
195
- let mSessions = $state<Session[]>([]);
220
+ let mSessions = $state<AdminSession[]>([]);
221
+ let mSessionsTotal = $state(0);
196
222
  let mSessionsPage = $state(1);
197
223
  let mSessionsSort = $state<SortState | null>(null);
198
- const mSessionsColumns: DataTableColumn<Session>[] = [
224
+ const mSessionsColumns: DataTableColumn<AdminSession>[] = [
199
225
  { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
200
226
  { key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
201
227
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
@@ -217,6 +243,7 @@
217
243
  mBanDays = "";
218
244
  mDeleteArmed = false;
219
245
  mSessions = [];
246
+ mSessionsTotal = 0;
220
247
  mSessionsPage = 1;
221
248
  manageOpen = true;
222
249
  await loadManagedSessions();
@@ -224,8 +251,15 @@
224
251
 
225
252
  async function loadManagedSessions(): Promise<void> {
226
253
  if (!mUser) return;
227
- const { data: res } = await api.auth.admin.listUserSessions({ userId: mUser.id });
228
- mSessions = (res?.sessions ?? []) as Session[];
254
+ const userId = mUser.id;
255
+ try {
256
+ const result = await listAdminSessions(api, { limit: MANAGED_SESSIONS_LIMIT, userId });
257
+ if (mUser?.id !== userId) return;
258
+ mSessions = result.items;
259
+ mSessionsTotal = result.total;
260
+ } catch {
261
+ toast.error(i18n.t.adminSessions.loadFailed);
262
+ }
229
263
  }
230
264
 
231
265
  async function saveProfile(event: Event): Promise<void> {
@@ -329,11 +363,15 @@
329
363
  await load();
330
364
  }
331
365
 
332
- async function revokeSession(token: string): Promise<void> {
366
+ async function revokeSession(id: string): Promise<void> {
333
367
  busy = true;
334
- const { error } = await api.auth.admin.revokeUserSession({ sessionToken: token });
335
- busy = false;
336
- if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
368
+ try {
369
+ await revokeAdminSession(api, id);
370
+ } catch {
371
+ return void toast.error(i18n.t.adminSessions.revokeFailed);
372
+ } finally {
373
+ busy = false;
374
+ }
337
375
  toast.success(i18n.t.adminSessions.revoked);
338
376
  await loadManagedSessions();
339
377
  }
@@ -343,7 +381,7 @@
343
381
  busy = true;
344
382
  const { error } = await api.auth.admin.revokeUserSessions({ userId: mUser.id });
345
383
  busy = false;
346
- if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
384
+ if (error) return void toast.error(error.message ?? i18n.t.adminSessions.revokeFailed);
347
385
  toast.success(i18n.t.adminSessions.allRevoked);
348
386
  await loadManagedSessions();
349
387
  }
@@ -360,7 +398,7 @@
360
398
  }
361
399
 
362
400
  $effect(() => {
363
- void load();
401
+ untrack(() => void load());
364
402
  });
365
403
  </script>
366
404
 
@@ -383,14 +421,18 @@
383
421
 
384
422
  <DataTable
385
423
  {columns}
386
- rows={filtered}
424
+ rows={users}
387
425
  getKey={(u) => u.id}
388
- empty={i18n.t.users.empty}
426
+ empty={loading ? "…" : i18n.t.users.empty}
389
427
  bind:sort
390
428
  bind:page
429
+ manualSort
430
+ manualPagination
391
431
  perPage={PAGE_SIZE}
432
+ {total}
392
433
  ariaLabel={i18n.t.users.title}
393
- label={fmt(i18n.t.users.total, { count: filtered.length })}
434
+ label={fmt(i18n.t.users.total, { count: total })}
435
+ onChange={() => void load()}
394
436
  >
395
437
  {#snippet row(user, { cellClass })}
396
438
  <Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
@@ -555,14 +597,14 @@
555
597
  bind:sort={mSessionsSort}
556
598
  bind:page={mSessionsPage}
557
599
  perPage={PAGE_SIZE}
558
- label={fmt(i18n.t.adminSessions.total, { count: mSessions.length })}
600
+ label={fmt(i18n.t.adminSessions.total, { count: mSessionsTotal })}
559
601
  empty={i18n.t.adminSessions.empty}
560
602
  >
561
603
  {#snippet row(s)}
562
604
  <Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
563
605
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
564
606
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
565
- <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
607
+ <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.id)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
566
608
  {/snippet}
567
609
  </DataTable>
568
610
  </div>
@@ -1,9 +1,8 @@
1
1
  import type { LayoutServerLoad } from "./$types";
2
2
  import { redirect } from "@sveltejs/kit";
3
3
  import { safeAuthRedirect } from "#lib/auth-redirect.js";
4
- import { loadMessages } from "#lib/i18n/messages.js";
4
+ import { getCapabilities } from "#lib/server/capabilities.js";
5
5
  import { isPublicPath, requireBackendAvailable } from "#lib/server/guards.js";
6
- import type { Capabilities } from "@podosoft/podokit-api-client";
7
6
 
8
7
  // "/" is a public landing page. Auth pages return signed-in users to their safe
9
8
  // requested page, and the admin area requires a session. /maintenance is public
@@ -43,19 +42,13 @@ export const load: LayoutServerLoad = async (event) => {
43
42
  if (!locals.user && !isPublicPath(pathname)) {
44
43
  redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
45
44
  }
46
- let sessionIdleTimeoutMinutes: number | null = null;
47
- if (locals.user) {
48
- try {
49
- const response = await event.fetch("/api/account/capabilities");
50
- if (response.ok) {
51
- const capabilities = (await response.json()) as Capabilities;
52
- sessionIdleTimeoutMinutes = capabilities.sessionIdleTimeoutMinutes ?? null;
53
- }
54
- } catch {
55
- // Keep automatic logout disabled in the browser if policy lookup fails.
56
- // The server-side session expiration remains authoritative.
57
- }
58
- }
59
- const messages = await loadMessages(locals.locale, site?.locale);
60
- return { user: locals.user, locale: locals.locale, messages, site, sessionIdleTimeoutMinutes };
45
+ // Automatic logout stays disabled in the browser if the policy lookup fails;
46
+ // the server-side session expiration remains authoritative. Protected loads
47
+ // of the same request reuse this capabilities response.
48
+ const sessionIdleTimeoutMinutes = locals.user
49
+ ? ((await getCapabilities(event)).sessionIdleTimeoutMinutes ?? null)
50
+ : null;
51
+ // Messages are resolved by the universal +layout.ts. This load re-runs on every
52
+ // client navigation for the path checks above and must not resend them.
53
+ return { user: locals.user, locale: locals.locale, site, sessionIdleTimeoutMinutes };
61
54
  };
@@ -0,0 +1,10 @@
1
+ import { loadMessages } from "#lib/i18n/messages.js";
2
+ import type { LayoutLoad } from "./$types";
3
+
4
+ // Runs on the server for the first render and in the browser afterwards, so the
5
+ // catalogs never travel in `__data.json`. loadMessages() reuses one merged
6
+ // object per locale pair; the language switch changes `data.locale`.
7
+ export const load: LayoutLoad = async ({ data }) => ({
8
+ ...data,
9
+ messages: await loadMessages(data.locale, data.site?.locale),
10
+ });
@@ -3,6 +3,7 @@
3
3
  import { Button } from "#lib/components/ui/button/index.js";
4
4
  import * as Card from "#lib/components/ui/card/index.js";
5
5
  import { getI18n } from "#lib/i18n/index.js";
6
+ import { signedOAuthQuery } from "#lib/oauth-query.js";
6
7
 
7
8
  const i18n = getI18n();
8
9
  // The OAuth provider redirects here with the authorization request in the query
@@ -14,13 +15,13 @@
14
15
  async function decide(accept: boolean): Promise<void> {
15
16
  busy = true;
16
17
  // The authorization request is carried in the signed query params we were
17
- // redirected here with; forward them verbatim so the server can validate and
18
- // restore it. Credentials carry the user's session. The response says where next.
18
+ // redirected here with; the server verifies them from the body, then restores
19
+ // the request. Credentials carry the user's session. The response says where next.
19
20
  const res = await fetch(`/api/auth/oauth2/consent${page.url.search}`, {
20
21
  method: "POST",
21
22
  credentials: "include",
22
23
  headers: { "content-type": "application/json" },
23
- body: JSON.stringify({ accept }),
24
+ body: JSON.stringify({ accept, oauth_query: signedOAuthQuery(page.url.search) }),
24
25
  });
25
26
  const data = (await res.json().catch(() => ({}))) as { url?: string; redirectURI?: string };
26
27
  const next = data.url ?? data.redirectURI;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { goto } from "$app/navigation";
3
- import QRCode from "qrcode";
4
3
  import { Button } from "#lib/components/ui/button/index.js";
5
4
  import { Input } from "#lib/components/ui/input/index.js";
6
5
  import { Label } from "#lib/components/ui/label/index.js";
@@ -21,13 +20,20 @@
21
20
 
22
21
  $effect(() => {
23
22
  const uri = setup?.totpURI;
24
- if (!uri) {
25
- qrDataUrl = "";
26
- return;
27
- }
28
- QRCode.toDataURL(uri, { margin: 1, width: 176 })
29
- .then((url) => (qrDataUrl = url))
30
- .catch(() => (qrDataUrl = ""));
23
+ qrDataUrl = "";
24
+ if (!uri) return;
25
+ let active = true;
26
+ import("qrcode")
27
+ .then(({ toDataURL }) => toDataURL(uri, { margin: 1, width: 176 }))
28
+ .then((url) => {
29
+ if (active && setup?.totpURI === uri) qrDataUrl = url;
30
+ })
31
+ .catch(() => {
32
+ if (active) qrDataUrl = "";
33
+ });
34
+ return () => {
35
+ active = false;
36
+ };
31
37
  });
32
38
 
33
39
  async function begin(): Promise<void> {
@@ -44,5 +44,6 @@ put pagination outside the table.
44
44
  above the DataTable. Filters + search apply together on the Search button/Enter
45
45
  (don't query on every keystroke); the search field is a `select` of columns.
46
46
 
47
- The `/admin/users` page is the reference implementation. See `docs/development.md`
47
+ The `/admin/users` page is the reference implementation (server offset paging);
48
+ `/admin/sessions` shows keyset paging with a cursor. See `docs/development.md`
48
49
  ("Data tables", "Search & filters") on the PodoKit repo.
@@ -0,0 +1,124 @@
1
+ import type { APIRequestContext, PlaywrightWorkerArgs } from "@playwright/test";
2
+ import { expect, test } from "../helpers/disposable-users";
3
+ import { ADMIN, USER } from "../helpers/accounts";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+ const origin = { origin: base };
7
+ const PASSWORD = "Podokit3e-Str0ng!pw";
8
+ // The rate-limit module's default ceiling for /api/auth/* per window. Listing
9
+ // sessions through Better Auth took 1 + one request per user from that budget;
10
+ // the sessions endpoint takes one general request per page.
11
+ const DEFAULT_AUTH_RATE_LIMIT = 20;
12
+
13
+ type SessionItem = {
14
+ id: string;
15
+ userId: string;
16
+ userEmail: string;
17
+ createdAt: string;
18
+ [key: string]: unknown;
19
+ };
20
+ type SessionPage = { items: SessionItem[]; nextCursor: string | null; total: number };
21
+
22
+ async function signedIn(
23
+ playwright: PlaywrightWorkerArgs["playwright"],
24
+ account: { email: string; password: string },
25
+ ): Promise<APIRequestContext> {
26
+ const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
+ expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
28
+ return context;
29
+ }
30
+
31
+ test("admin pages through every user's session with a few requests outside the auth budget", async ({
32
+ playwright,
33
+ disposableUsers,
34
+ }) => {
35
+ test.setTimeout(180_000);
36
+ const prefix = `sess-page-${Date.now()}`;
37
+ const userCount = 27;
38
+ const pageSize = 10;
39
+ const userIds = new Set<string>();
40
+ const signIns = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
41
+ for (let index = 0; index < userCount; index += 1) {
42
+ const email = `${prefix}-${String(index).padStart(2, "0")}@example.com`;
43
+ userIds.add(await disposableUsers.create({ email, name: `Session pager ${index}` }));
44
+ expect((await signIns.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok())
45
+ .toBeTruthy();
46
+ }
47
+ await signIns.dispose();
48
+
49
+ const admin = await signedIn(playwright, ADMIN);
50
+ const seen: SessionItem[] = [];
51
+ let requests = 0;
52
+ let cursor: string | null = null;
53
+ do {
54
+ const query = new URLSearchParams({ limit: String(pageSize), search: prefix });
55
+ if (cursor) query.set("cursor", cursor);
56
+ const response = await admin.get(`/api/admin/sessions?${query.toString()}`);
57
+ requests += 1;
58
+ expect(response.status()).toBe(200);
59
+ const remaining = response.headers()["ratelimit-remaining"];
60
+ if (remaining !== undefined) expect(Number(remaining)).toBeGreaterThan(0);
61
+ const page = (await response.json()) as SessionPage;
62
+ expect(page.total).toBe(userCount);
63
+ for (const item of page.items) expect(item).not.toHaveProperty("token");
64
+ seen.push(...page.items);
65
+ cursor = page.nextCursor;
66
+ } while (cursor && requests <= userCount);
67
+
68
+ expect(requests).toBe(Math.ceil(userCount / pageSize));
69
+ expect(requests).toBeLessThan(DEFAULT_AUTH_RATE_LIMIT);
70
+ expect(new Set(seen.map((item) => item.id)).size).toBe(userCount);
71
+ expect(new Set(seen.map((item) => item.userId))).toEqual(userIds);
72
+ const created = seen.map((item) => Date.parse(item.createdAt));
73
+ expect(created).toEqual([...created].sort((left, right) => right - left));
74
+ await admin.dispose();
75
+ });
76
+
77
+ test("a normal user cannot list or revoke sessions", async ({ playwright }) => {
78
+ const user = await signedIn(playwright, USER);
79
+ expect((await user.get("/api/admin/sessions")).status()).toBe(403);
80
+ expect((await user.delete("/api/admin/sessions/any-session")).status()).toBe(403);
81
+ await user.dispose();
82
+ });
83
+
84
+ test("admin revokes a session by id and the action is audited", async ({ playwright, disposableUsers }) => {
85
+ const email = `sess-revoke-${Date.now()}@example.com`;
86
+ const userId = await disposableUsers.create({ email, name: "Revoke by id" });
87
+ const theirs = await signedIn(playwright, { email, password: PASSWORD });
88
+ const admin = await signedIn(playwright, ADMIN);
89
+
90
+ const listed = await admin.get(`/api/admin/sessions?userId=${encodeURIComponent(userId)}`);
91
+ expect(listed.ok()).toBeTruthy();
92
+ const page = (await listed.json()) as SessionPage;
93
+ expect(page.items).toHaveLength(1);
94
+ const sessionId = page.items[0]?.id ?? "";
95
+
96
+ const revoked = await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`);
97
+ expect(revoked.status()).toBe(204);
98
+ expect(await (await theirs.get("/api/auth/get-session")).json()).toBeNull();
99
+ expect((await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`)).status()).toBe(404);
100
+
101
+ // The revoke runs through Better Auth, so the audit-log module records it when
102
+ // the module is installed and auditing is on.
103
+ const capabilities = (await (await admin.get("/api/account/capabilities")).json()) as { auditLog?: boolean };
104
+ const audit = await admin.get("/api/audit-logs");
105
+ if (capabilities.auditLog === true && audit.ok()) {
106
+ const body = (await audit.json()) as unknown;
107
+ const entries = (Array.isArray(body) ? body : (body as { items?: unknown[] }).items ?? []) as Array<{
108
+ action?: string;
109
+ actorEmail?: string | null;
110
+ }>;
111
+ expect(entries.some((entry) => entry.action === "user.session_revoke" && entry.actorEmail === ADMIN.email))
112
+ .toBeTruthy();
113
+ }
114
+ await theirs.dispose();
115
+ await admin.dispose();
116
+ });
117
+
118
+ test("a forged cursor is rejected", async ({ playwright }) => {
119
+ const admin = await signedIn(playwright, ADMIN);
120
+ const response = await admin.get("/api/admin/sessions?cursor=forged");
121
+ expect(response.status()).toBe(400);
122
+ expect((await response.json())?.error?.code).toBe("ADMIN_SESSION_CURSOR_INVALID");
123
+ await admin.dispose();
124
+ });
@@ -0,0 +1,86 @@
1
+ import type { APIRequestContext, PlaywrightWorkerArgs } from "@playwright/test";
2
+ import { expect, test } from "../helpers/disposable-users";
3
+ import { ADMIN, USER } from "../helpers/accounts";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+ const origin = { origin: base };
7
+
8
+ type UserItem = {
9
+ id: string;
10
+ email: string;
11
+ role: string | null;
12
+ banned: boolean;
13
+ signupApproved: boolean;
14
+ [key: string]: unknown;
15
+ };
16
+ type UserPage = { items: UserItem[]; total: number };
17
+
18
+ async function signedIn(
19
+ playwright: PlaywrightWorkerArgs["playwright"],
20
+ account: { email: string; password: string },
21
+ ): Promise<APIRequestContext> {
22
+ const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
23
+ expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
24
+ return context;
25
+ }
26
+
27
+ async function list(admin: APIRequestContext, query: Record<string, string>): Promise<UserPage> {
28
+ const response = await admin.get(`/api/admin/users?${new URLSearchParams(query).toString()}`);
29
+ expect(response.status(), await response.text()).toBe(200);
30
+ return (await response.json()) as UserPage;
31
+ }
32
+
33
+ test("the admin user list combines role and status filters with a search", async ({
34
+ playwright,
35
+ disposableUsers,
36
+ }) => {
37
+ const prefix = `users-filter-${Date.now()}`;
38
+ const ids = {
39
+ activeAdmin: await disposableUsers.create({ email: `${prefix}-a@example.com`, role: "admin" }),
40
+ bannedAdmin: await disposableUsers.create({ email: `${prefix}-b@example.com`, role: "admin" }),
41
+ activeUser: await disposableUsers.create({ email: `${prefix}-c@example.com` }),
42
+ pendingUser: await disposableUsers.create({ email: `${prefix}-d@example.com` }),
43
+ };
44
+ const admin = await signedIn(playwright, ADMIN);
45
+ try {
46
+ expect((await admin.post("/api/auth/admin/ban-user", { data: { userId: ids.bannedAdmin } })).ok())
47
+ .toBeTruthy();
48
+ expect((await admin.post("/api/auth/admin/update-user", {
49
+ data: { userId: ids.pendingUser, data: { signupApproved: false } },
50
+ })).ok()).toBeTruthy();
51
+
52
+ const matching = async (role: string, status: string): Promise<string[]> =>
53
+ (await list(admin, { search: prefix.toUpperCase(), role, status })).items.map((user) => user.id);
54
+ expect(await matching("admin", "active")).toEqual([ids.activeAdmin]);
55
+ expect(await matching("admin", "banned")).toEqual([ids.bannedAdmin]);
56
+ expect(await matching("user", "pending")).toEqual([ids.pendingUser]);
57
+ expect(await matching("user", "active")).toEqual([ids.activeUser]);
58
+
59
+ const all = await list(admin, { search: prefix, limit: "2", sortBy: "email", sortDirection: "asc" });
60
+ expect(all.total).toBe(4);
61
+ expect(all.items.map((user) => user.id)).toEqual([ids.activeAdmin, ids.bannedAdmin]);
62
+ const banned = all.items[1];
63
+ expect(banned?.banned).toBe(true);
64
+ expect(banned?.signupApproved).toBe(true);
65
+ expect(banned).not.toHaveProperty("password");
66
+ } finally {
67
+ await admin.dispose();
68
+ }
69
+ });
70
+
71
+ test("the admin user list is for administrators and validates its query", async ({ playwright }) => {
72
+ const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
73
+ const user = await signedIn(playwright, USER);
74
+ const admin = await signedIn(playwright, ADMIN);
75
+ try {
76
+ expect((await anonymous.get("/api/admin/users")).status()).toBe(401);
77
+ expect((await user.get("/api/admin/users")).status()).toBe(403);
78
+ for (const query of ["status=deleted", "sortBy=password", "limit=101"]) {
79
+ expect((await admin.get(`/api/admin/users?${query}`)).status(), query).toBe(400);
80
+ }
81
+ } finally {
82
+ await anonymous.dispose();
83
+ await user.dispose();
84
+ await admin.dispose();
85
+ }
86
+ });