@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
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { Badge } from "$lib/components/ui/badge";
3
3
  import * as Table from "$lib/components/ui/table";
4
- import TablePagination from "$lib/components/table-pagination.svelte";
4
+ import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
5
+ import TableToolbar, { type ToolbarFilter, type ToolbarSearchField } from "$lib/components/table-toolbar.svelte";
5
6
  import { toast } from "svelte-sonner";
6
7
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
7
8
 
@@ -17,10 +18,52 @@
17
18
  createdAt: string;
18
19
  };
19
20
 
20
- const PAGE_SIZE = 10;
21
21
  let entries = $state<Entry[]>([]);
22
22
  let page = $state(1);
23
- const paged = $derived(entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
23
+ let sort = $state<SortState | null>({ key: "createdAt", dir: "desc" });
24
+ let search = $state("");
25
+ let appliedSearch = $state("");
26
+ let filterValues = $state<Record<string, string>>({ action: "" });
27
+ let appliedFilters = $state<Record<string, string>>({ action: "" });
28
+ let searchField = $state("actor");
29
+ let appliedSearchField = $state("actor");
30
+
31
+ const columns: DataTableColumn<Entry>[] = [
32
+ { key: "createdAt", label: i18n.t.audit.when, sortable: true, class: "whitespace-nowrap" },
33
+ { key: "actor", label: i18n.t.audit.actor, sortable: true, value: (e) => e.actorName ?? e.actorEmail },
34
+ { key: "action", label: i18n.t.audit.action, sortable: true },
35
+ { key: "target", label: i18n.t.audit.target, sortable: true, value: (e) => e.targetLabel },
36
+ { key: "ip", label: i18n.t.audit.ip, sortable: true },
37
+ ];
38
+
39
+ const actions = $derived([...new Set(entries.map((e) => e.action))].sort());
40
+ const filters = $derived<ToolbarFilter[]>([
41
+ {
42
+ key: "action",
43
+ label: i18n.t.audit.action,
44
+ options: [{ value: "", label: i18n.t.toolbar.all }, ...actions.map((a) => ({ value: a, label: a }))],
45
+ },
46
+ ]);
47
+
48
+ const searchFields: ToolbarSearchField[] = [
49
+ { value: "actor", label: i18n.t.audit.actor },
50
+ { value: "target", label: i18n.t.audit.target },
51
+ ];
52
+
53
+ const filtered = $derived(
54
+ entries.filter((e) => {
55
+ if (appliedFilters.action && e.action !== appliedFilters.action) return false;
56
+ if (appliedSearch) {
57
+ const hay = (
58
+ appliedSearchField === "target"
59
+ ? (e.targetLabel ?? "")
60
+ : `${e.actorName ?? ""} ${e.actorEmail ?? ""}`
61
+ ).toLowerCase();
62
+ if (!hay.includes(appliedSearch.toLowerCase())) return false;
63
+ }
64
+ return true;
65
+ }),
66
+ );
24
67
 
25
68
  async function load(): Promise<void> {
26
69
  try {
@@ -40,39 +83,41 @@
40
83
  <div class="flex flex-col gap-4">
41
84
  <h1 class="text-2xl font-semibold">{i18n.t.audit.title}</h1>
42
85
 
43
- <div class="rounded-md border">
44
- <Table.Root>
45
- <Table.Header>
46
- <Table.Row>
47
- <Table.Head>{i18n.t.audit.when}</Table.Head>
48
- <Table.Head>{i18n.t.audit.actor}</Table.Head>
49
- <Table.Head>{i18n.t.audit.action}</Table.Head>
50
- <Table.Head>{i18n.t.audit.target}</Table.Head>
51
- <Table.Head>{i18n.t.audit.ip}</Table.Head>
52
- </Table.Row>
53
- </Table.Header>
54
- <Table.Body>
55
- {#each paged as e (e.id)}
56
- <Table.Row>
57
- <Table.Cell class="text-muted-foreground whitespace-nowrap">{formatDateTime(e.createdAt)}</Table.Cell>
58
- <Table.Cell>
59
- {#if e.actorName || e.actorEmail}
60
- <div class="font-medium">{e.actorName ?? e.actorEmail}</div>
61
- {#if e.actorName && e.actorEmail}<div class="text-muted-foreground text-xs">{e.actorEmail}</div>{/if}
62
- {:else}
63
- <span class="text-muted-foreground">—</span>
64
- {/if}
65
- </Table.Cell>
66
- <Table.Cell><Badge variant="secondary" class="font-mono text-xs">{e.action}</Badge></Table.Cell>
67
- <Table.Cell class="max-w-xs truncate">{e.targetLabel ?? "—"}</Table.Cell>
68
- <Table.Cell class="text-muted-foreground">{e.ip ?? "—"}</Table.Cell>
69
- </Table.Row>
70
- {:else}
71
- <Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{i18n.t.audit.empty}</Table.Cell></Table.Row>
72
- {/each}
73
- </Table.Body>
74
- </Table.Root>
75
- </div>
86
+ <TableToolbar
87
+ {filters}
88
+ bind:filterValues
89
+ {searchFields}
90
+ bind:searchField
91
+ bind:search
92
+ filterHeading={i18n.t.toolbar.filter}
93
+ searchHeading={i18n.t.toolbar.search}
94
+ searchButton={i18n.t.toolbar.searchButton}
95
+ onSearch={() => { appliedSearch = search; appliedSearchField = searchField; appliedFilters = { ...filterValues }; page = 1; }}
96
+ />
76
97
 
77
- <TablePagination count={entries.length} perPage={PAGE_SIZE} bind:page label={fmt(i18n.t.audit.total, { count: entries.length })} />
98
+ <DataTable
99
+ {columns}
100
+ rows={filtered}
101
+ getKey={(e) => e.id}
102
+ empty={i18n.t.audit.empty}
103
+ bind:sort
104
+ bind:page
105
+ perPage={DEFAULT_PAGE_SIZE}
106
+ label={fmt(i18n.t.audit.total, { count: filtered.length })}
107
+ >
108
+ {#snippet row(e)}
109
+ <Table.Cell class="text-muted-foreground whitespace-nowrap">{formatDateTime(e.createdAt)}</Table.Cell>
110
+ <Table.Cell>
111
+ {#if e.actorName || e.actorEmail}
112
+ <div class="font-medium">{e.actorName ?? e.actorEmail}</div>
113
+ {#if e.actorName && e.actorEmail}<div class="text-muted-foreground text-xs">{e.actorEmail}</div>{/if}
114
+ {:else}
115
+ <span class="text-muted-foreground">—</span>
116
+ {/if}
117
+ </Table.Cell>
118
+ <Table.Cell><Badge variant="secondary" class="font-mono text-xs">{e.action}</Badge></Table.Cell>
119
+ <Table.Cell class="max-w-xs truncate">{e.targetLabel ?? "—"}</Table.Cell>
120
+ <Table.Cell class="text-muted-foreground">{e.ip ?? "—"}</Table.Cell>
121
+ {/snippet}
122
+ </DataTable>
78
123
  </div>
@@ -0,0 +1,6 @@
1
+ import type { PageServerLoad } from "./$types";
2
+ import { requireAdmin } from "$lib/server/guards";
3
+
4
+ export const load: PageServerLoad = ({ locals }) => {
5
+ requireAdmin(locals.user);
6
+ };
@@ -0,0 +1,264 @@
1
+ <script lang="ts">
2
+ import { Button } from "$lib/components/ui/button";
3
+ import { Input } from "$lib/components/ui/input";
4
+ import { Label } from "$lib/components/ui/label";
5
+ import { Checkbox } from "$lib/components/ui/checkbox";
6
+ import * as Table from "$lib/components/ui/table";
7
+ import * as Dialog from "$lib/components/ui/dialog";
8
+ import * as Select from "$lib/components/ui/select";
9
+ import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
10
+ import { toast } from "svelte-sonner";
11
+ import { api } from "$lib/api";
12
+ import { getI18n, fmt, formatDateTime } from "$lib/i18n";
13
+
14
+ const i18n = getI18n();
15
+
16
+ type Org = { id: string; name: string; slug: string; parentOrganizationId?: string | null; createdAt: string | Date };
17
+ let orgs = $state<Org[]>([]);
18
+ let sort = $state<SortState | null>({ key: "createdAt", dir: "desc" });
19
+ let listPage = $state(1);
20
+ const columns: DataTableColumn<Org>[] = [
21
+ { key: "name", label: i18n.t.organizations.name, sortable: true },
22
+ { key: "slug", label: i18n.t.organizations.slug, sortable: true },
23
+ { key: "parent", label: i18n.t.organizations.parent, sortable: true, value: (o) => orgName(o.parentOrganizationId) },
24
+ { key: "createdAt", label: i18n.t.organizations.created, sortable: true },
25
+ { key: "actions", label: "", class: "w-10" },
26
+ ];
27
+ let busy = $state(false);
28
+ let createOpen = $state(false);
29
+ let form = $state({ name: "", slug: "", parentOrganizationId: "" });
30
+ // Existing users, so managers can be picked at creation (empty = none).
31
+ type AppUser = { id: string; name: string; email: string };
32
+ let users = $state<AppUser[]>([]);
33
+ let managerIds = $state<string[]>([]);
34
+
35
+ const orgName = (id?: string | null): string => (id ? (orgs.find((o) => o.id === id)?.name ?? "—") : "—");
36
+
37
+ async function load(): Promise<void> {
38
+ const { data } = await api.auth.organization.list();
39
+ orgs = (data ?? []) as Org[];
40
+ }
41
+ async function loadUsers(): Promise<void> {
42
+ const { data } = await api.auth.admin.listUsers({ query: { limit: 100 } });
43
+ users = ((data?.users ?? []) as AppUser[]);
44
+ }
45
+
46
+ // Derive a slug from the name; fall back to a unique one for non-ASCII names.
47
+ const slugify = (s: string): string =>
48
+ s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
49
+ const derivedSlug = (name: string): string => slugify(name) || `org-${Date.now().toString(36)}`;
50
+
51
+ function toggleManager(id: string): void {
52
+ managerIds = managerIds.includes(id) ? managerIds.filter((m) => m !== id) : [...managerIds, id];
53
+ }
54
+
55
+ // parentOrganizationId is a server additionalField not in the client's inferred
56
+ // create type; assert at the call boundary.
57
+ type CreateArg = Parameters<typeof api.auth.organization.create>[0];
58
+
59
+ async function createOrg(event: SubmitEvent): Promise<void> {
60
+ event.preventDefault();
61
+ busy = true;
62
+ const slug = form.slug.trim() || derivedSlug(form.name);
63
+ const { data: org, error } = await api.auth.organization.create({
64
+ name: form.name.trim(),
65
+ slug,
66
+ ...(form.parentOrganizationId ? { parentOrganizationId: form.parentOrganizationId } : {}),
67
+ } as CreateArg);
68
+ if (error) {
69
+ busy = false;
70
+ return void toast.error(error.message ?? i18n.t.organizations.actionFailed);
71
+ }
72
+ // Add the chosen existing users as managers (role-based, so any number). Goes
73
+ // through our /account/org-member endpoint (better-auth's addMember is server-only).
74
+ const orgId = (org as { id?: string } | null)?.id;
75
+ if (orgId) {
76
+ for (const userId of managerIds) {
77
+ await api.post("/account/org-member", { organizationId: orgId, userId, role: "manager" }).catch(() => undefined);
78
+ }
79
+ }
80
+ busy = false;
81
+ toast.success(i18n.t.organizations.createdMsg);
82
+ createOpen = false;
83
+ form = { name: "", slug: "", parentOrganizationId: "" };
84
+ managerIds = [];
85
+ await load();
86
+ }
87
+
88
+ async function deleteOrg(organizationId: string): Promise<void> {
89
+ busy = true;
90
+ const { error } = await api.auth.organization.delete({ organizationId });
91
+ busy = false;
92
+ if (error) return void toast.error(error.message ?? i18n.t.organizations.actionFailed);
93
+ toast.success(i18n.t.organizations.deletedMsg);
94
+ await load();
95
+ }
96
+
97
+ // Members & invitations for one organization.
98
+ type Member = { id: string; role: string; user: { email: string; name: string } };
99
+ type Invitation = { id: string; email: string; role: string; status: string };
100
+ let manageOpen = $state(false);
101
+ let manageOrg = $state<Org | null>(null);
102
+ let members = $state<Member[]>([]);
103
+ let invitations = $state<Invitation[]>([]);
104
+ let inviteEmail = $state("");
105
+
106
+ async function openManage(org: Org): Promise<void> {
107
+ manageOrg = org;
108
+ manageOpen = true;
109
+ members = [];
110
+ invitations = [];
111
+ await refreshMembers();
112
+ }
113
+ async function refreshMembers(): Promise<void> {
114
+ if (!manageOrg) return;
115
+ const { data } = await api.auth.organization.getFullOrganization({ query: { organizationId: manageOrg.id } });
116
+ members = ((data as { members?: Member[] } | null)?.members ?? []) as Member[];
117
+ invitations = (((data as { invitations?: Invitation[] } | null)?.invitations ?? []) as Invitation[]).filter((i) => i.status === "pending");
118
+ }
119
+ async function invite(event: SubmitEvent): Promise<void> {
120
+ event.preventDefault();
121
+ if (!manageOrg) return;
122
+ busy = true;
123
+ const { error } = await api.auth.organization.inviteMember({ email: inviteEmail.trim(), role: "member", organizationId: manageOrg.id });
124
+ busy = false;
125
+ if (error) return void toast.error(error.message ?? i18n.t.organizations.actionFailed);
126
+ toast.success(i18n.t.organizations.invited);
127
+ inviteEmail = "";
128
+ await refreshMembers();
129
+ }
130
+ async function removeMember(memberIdOrEmail: string): Promise<void> {
131
+ if (!manageOrg) return;
132
+ busy = true;
133
+ const { error } = await api.auth.organization.removeMember({ memberIdOrEmail, organizationId: manageOrg.id });
134
+ busy = false;
135
+ if (error) return void toast.error(error.message ?? i18n.t.organizations.actionFailed);
136
+ await refreshMembers();
137
+ }
138
+ async function cancelInvite(invitationId: string): Promise<void> {
139
+ busy = true;
140
+ const { error } = await api.auth.organization.cancelInvitation({ invitationId });
141
+ busy = false;
142
+ if (error) return void toast.error(error.message ?? i18n.t.organizations.actionFailed);
143
+ await refreshMembers();
144
+ }
145
+
146
+ $effect(() => {
147
+ void load();
148
+ void loadUsers();
149
+ });
150
+ </script>
151
+
152
+ <div class="flex flex-col gap-6">
153
+ <div class="flex items-center justify-between gap-2">
154
+ <div>
155
+ <h1 class="text-2xl font-semibold">{i18n.t.organizations.title}</h1>
156
+ <p class="text-muted-foreground text-sm">{i18n.t.organizations.subtitle}</p>
157
+ </div>
158
+ <Button onclick={() => (createOpen = true)}>{i18n.t.organizations.create}</Button>
159
+ </div>
160
+
161
+ <DataTable
162
+ {columns}
163
+ rows={orgs}
164
+ getKey={(o) => o.id}
165
+ bind:sort
166
+ bind:page={listPage}
167
+ perPage={DEFAULT_PAGE_SIZE}
168
+ label={fmt(i18n.t.organizations.total, { count: orgs.length })}
169
+ empty={i18n.t.organizations.empty}
170
+ >
171
+ {#snippet row(org)}
172
+ <Table.Cell class="font-medium">{org.name}</Table.Cell>
173
+ <Table.Cell class="text-muted-foreground font-mono text-xs">{org.slug}</Table.Cell>
174
+ <Table.Cell class="text-muted-foreground">{orgName(org.parentOrganizationId)}</Table.Cell>
175
+ <Table.Cell class="text-muted-foreground">{formatDateTime(org.createdAt)}</Table.Cell>
176
+ <Table.Cell class="flex justify-end gap-1">
177
+ <Button variant="ghost" size="sm" onclick={() => openManage(org)}>{i18n.t.organizations.manage}</Button>
178
+ <Button variant="ghost" size="sm" disabled={busy} onclick={() => deleteOrg(org.id)}>{i18n.t.organizations.delete}</Button>
179
+ </Table.Cell>
180
+ {/snippet}
181
+ </DataTable>
182
+ </div>
183
+
184
+ <Dialog.Root bind:open={manageOpen}>
185
+ <Dialog.Content class="max-w-lg">
186
+ <Dialog.Header>
187
+ <Dialog.Title>{manageOrg?.name}</Dialog.Title>
188
+ <Dialog.Description>{i18n.t.organizations.membersDesc}</Dialog.Description>
189
+ </Dialog.Header>
190
+ <form class="flex items-end gap-2" onsubmit={invite}>
191
+ <div class="flex flex-1 flex-col gap-1">
192
+ <Label for="invite-email">{i18n.t.organizations.inviteEmail}</Label>
193
+ <Input id="invite-email" type="email" bind:value={inviteEmail} placeholder="teammate@example.com" required />
194
+ </div>
195
+ <Button type="submit" disabled={busy || !inviteEmail.trim()}>{i18n.t.organizations.invite}</Button>
196
+ </form>
197
+ <div class="flex flex-col gap-2">
198
+ <p class="text-sm font-medium">{i18n.t.organizations.members}</p>
199
+ {#each members as m (m.id)}
200
+ <div class="flex items-center justify-between gap-2 rounded-md border p-2 text-sm">
201
+ <div><span class="font-medium">{m.user.name || m.user.email}</span> <span class="text-muted-foreground">· {m.role}</span></div>
202
+ {#if m.role !== "owner"}
203
+ <Button variant="ghost" size="sm" disabled={busy} onclick={() => removeMember(m.id)}>{i18n.t.organizations.remove}</Button>
204
+ {/if}
205
+ </div>
206
+ {/each}
207
+ {#if invitations.length}
208
+ <p class="text-sm font-medium">{i18n.t.organizations.pending}</p>
209
+ {#each invitations as inv (inv.id)}
210
+ <div class="flex items-center justify-between gap-2 rounded-md border border-dashed p-2 text-sm">
211
+ <span class="text-muted-foreground">{inv.email} · {inv.role}</span>
212
+ <Button variant="ghost" size="sm" disabled={busy} onclick={() => cancelInvite(inv.id)}>{i18n.t.organizations.cancelInvite}</Button>
213
+ </div>
214
+ {/each}
215
+ {/if}
216
+ </div>
217
+ </Dialog.Content>
218
+ </Dialog.Root>
219
+
220
+ <Dialog.Root bind:open={createOpen}>
221
+ <Dialog.Content>
222
+ <Dialog.Header>
223
+ <Dialog.Title>{i18n.t.organizations.createTitle}</Dialog.Title>
224
+ </Dialog.Header>
225
+ <form class="flex flex-col gap-3" onsubmit={createOrg}>
226
+ <div class="flex flex-col gap-1">
227
+ <Label for="o-name">{i18n.t.organizations.name}</Label>
228
+ <Input id="o-name" bind:value={form.name} required />
229
+ </div>
230
+ <div class="flex flex-col gap-1">
231
+ <Label for="o-slug">{i18n.t.organizations.slug}</Label>
232
+ <Input id="o-slug" bind:value={form.slug} placeholder={form.name ? derivedSlug(form.name) : "acme"} />
233
+ </div>
234
+ <div class="flex flex-col gap-1">
235
+ <Label for="o-parent">{i18n.t.organizations.parent}</Label>
236
+ <Select.Root type="single" bind:value={form.parentOrganizationId}>
237
+ <Select.Trigger id="o-parent" class="w-full">{form.parentOrganizationId ? orgName(form.parentOrganizationId) : i18n.t.organizations.noParent}</Select.Trigger>
238
+ <Select.Content>
239
+ <Select.Item value="">{i18n.t.organizations.noParent}</Select.Item>
240
+ {#each orgs as o (o.id)}<Select.Item value={o.id}>{o.name}</Select.Item>{/each}
241
+ </Select.Content>
242
+ </Select.Root>
243
+ </div>
244
+ {#if users.length}
245
+ <div class="flex flex-col gap-1">
246
+ <Label>{i18n.t.organizations.managers}</Label>
247
+ <p class="text-muted-foreground text-xs">{i18n.t.organizations.managersHint}</p>
248
+ <div class="max-h-40 overflow-y-auto rounded-md border p-2">
249
+ {#each users as u (u.id)}
250
+ <Label class="flex items-center gap-2 py-1 text-sm font-normal">
251
+ <Checkbox checked={managerIds.includes(u.id)} onCheckedChange={() => toggleManager(u.id)} />
252
+ {u.name || u.email} <span class="text-muted-foreground">· {u.email}</span>
253
+ </Label>
254
+ {/each}
255
+ </div>
256
+ </div>
257
+ {/if}
258
+ <Dialog.Footer>
259
+ <Button type="button" variant="outline" onclick={() => (createOpen = false)}>{i18n.t.organizations.cancel}</Button>
260
+ <Button type="submit" disabled={busy || !form.name.trim()}>{i18n.t.organizations.submit}</Button>
261
+ </Dialog.Footer>
262
+ </form>
263
+ </Dialog.Content>
264
+ </Dialog.Root>
@@ -1,8 +1,7 @@
1
1
  import type { PageServerLoad } from "./$types";
2
- import { error } from "@sveltejs/kit";
2
+ import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- if (locals.user?.role !== "admin") {
6
- error(403, "Admins only");
7
- }
5
+ requireAdmin(locals.user);
6
+ return { currentSessionId: locals.session?.id ?? null };
8
7
  };
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { Button } from "$lib/components/ui/button";
3
+ import { Badge } from "$lib/components/ui/badge";
3
4
  import * as Table from "$lib/components/ui/table";
4
5
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
5
- import TablePagination from "$lib/components/table-pagination.svelte";
6
+ import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
7
+ import TableToolbar, { type ToolbarSearchField } from "$lib/components/table-toolbar.svelte";
6
8
  import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
7
9
  import { toast } from "svelte-sonner";
8
10
  import { api } from "$lib/api";
@@ -23,14 +25,43 @@
23
25
  userAgent?: string | null;
24
26
  ipAddress?: string | null;
25
27
  createdAt: string | Date;
28
+ expiresAt?: string | Date | null;
29
+ impersonatedBy?: string | null;
26
30
  };
27
31
 
28
- const PAGE_SIZE = 5;
29
32
  let rows = $state<Row[]>([]);
30
33
  let page = $state(1);
34
+ // No default sort: keep the fan-out order so the newest session isn't required
35
+ // to sit on page 1. Sorting is available by clicking a column header.
36
+ let sort = $state<SortState | null>(null);
31
37
  let loading = $state(false);
32
38
  let busy = $state(false);
33
- const pagedRows = $derived(rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
39
+ let search = $state("");
40
+ let appliedSearch = $state("");
41
+ let searchField = $state("email");
42
+ let appliedSearchField = $state("email");
43
+
44
+ const columns: DataTableColumn<Row>[] = [
45
+ { key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
46
+ { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
47
+ { key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
48
+ { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
49
+ { key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
50
+ { key: "actions", label: "", class: "w-10" },
51
+ ];
52
+
53
+ const searchFields: ToolbarSearchField[] = [
54
+ { value: "email", label: i18n.t.users.email },
55
+ { value: "name", label: i18n.t.users.name },
56
+ ];
57
+
58
+ const filtered = $derived(
59
+ rows.filter((r) => {
60
+ if (!appliedSearch) return true;
61
+ const hay = (appliedSearchField === "name" ? r.userName : r.userEmail).toLowerCase();
62
+ return hay.includes(appliedSearch.toLowerCase());
63
+ }),
64
+ );
34
65
 
35
66
  async function load(): Promise<void> {
36
67
  loading = true;
@@ -76,47 +107,50 @@
76
107
  <div class="flex flex-col gap-4">
77
108
  <h1 class="text-2xl font-semibold">{i18n.t.adminSessions.title}</h1>
78
109
 
79
- <div class="rounded-md border">
80
- <Table.Root>
81
- <Table.Header>
82
- <Table.Row>
83
- <Table.Head>{i18n.t.adminSessions.user}</Table.Head>
84
- <Table.Head>{i18n.t.adminSessions.device}</Table.Head>
85
- <Table.Head>{i18n.t.adminSessions.ip}</Table.Head>
86
- <Table.Head>{i18n.t.adminSessions.since}</Table.Head>
87
- <Table.Head class="w-10"></Table.Head>
88
- </Table.Row>
89
- </Table.Header>
90
- <Table.Body>
91
- {#each pagedRows as row (row.id)}
92
- <Table.Row>
93
- <Table.Cell>
94
- <div class="font-medium">{row.userName}</div>
95
- <div class="text-muted-foreground text-xs">{row.userEmail}</div>
96
- </Table.Cell>
97
- <Table.Cell class="max-w-xs truncate">{row.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
98
- <Table.Cell class="text-muted-foreground">{row.ipAddress ?? "—"}</Table.Cell>
99
- <Table.Cell class="text-muted-foreground">{formatDateTime(row.createdAt)}</Table.Cell>
100
- <Table.Cell>
101
- <DropdownMenu.Root>
102
- <DropdownMenu.Trigger>
103
- {#snippet child({ props })}
104
- <Button {...props} variant="ghost" size="icon" disabled={busy}><EllipsisIcon class="size-4" /></Button>
105
- {/snippet}
106
- </DropdownMenu.Trigger>
107
- <DropdownMenu.Content align="end">
108
- <DropdownMenu.Item onSelect={() => revoke(row)}>{i18n.t.adminSessions.revoke}</DropdownMenu.Item>
109
- <DropdownMenu.Item onSelect={() => revokeAll(row)}>{i18n.t.adminSessions.revokeAll}</DropdownMenu.Item>
110
- </DropdownMenu.Content>
111
- </DropdownMenu.Root>
112
- </Table.Cell>
113
- </Table.Row>
114
- {:else}
115
- <Table.Row><Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">{loading ? "…" : i18n.t.adminSessions.empty}</Table.Cell></Table.Row>
116
- {/each}
117
- </Table.Body>
118
- </Table.Root>
119
- </div>
110
+ <TableToolbar
111
+ {searchFields}
112
+ bind:searchField
113
+ bind:search
114
+ searchHeading={i18n.t.toolbar.search}
115
+ searchButton={i18n.t.toolbar.searchButton}
116
+ onSearch={() => { appliedSearch = search; appliedSearchField = searchField; page = 1; }}
117
+ />
120
118
 
121
- <TablePagination count={rows.length} perPage={PAGE_SIZE} bind:page label={fmt(i18n.t.adminSessions.total, { count: rows.length })} />
119
+ <DataTable
120
+ {columns}
121
+ rows={filtered}
122
+ getKey={(r) => r.id}
123
+ empty={loading ? "…" : i18n.t.adminSessions.empty}
124
+ bind:sort
125
+ bind:page
126
+ perPage={DEFAULT_PAGE_SIZE}
127
+ label={fmt(i18n.t.adminSessions.total, { count: filtered.length })}
128
+ >
129
+ {#snippet row(r)}
130
+ <Table.Cell>
131
+ <div class="flex items-center gap-2">
132
+ <div class="font-medium">{r.userName}</div>
133
+ {#if r.impersonatedBy}<Badge variant="outline">{i18n.t.adminSessions.impersonated}</Badge>{/if}
134
+ </div>
135
+ <div class="text-muted-foreground text-xs">{r.userEmail}</div>
136
+ </Table.Cell>
137
+ <Table.Cell class="max-w-xs truncate">{r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
138
+ <Table.Cell class="text-muted-foreground">{r.ipAddress ?? "—"}</Table.Cell>
139
+ <Table.Cell class="text-muted-foreground">{formatDateTime(r.createdAt)}</Table.Cell>
140
+ <Table.Cell class="text-muted-foreground">{r.expiresAt ? formatDateTime(r.expiresAt) : "—"}</Table.Cell>
141
+ <Table.Cell>
142
+ <DropdownMenu.Root>
143
+ <DropdownMenu.Trigger>
144
+ {#snippet child({ props })}
145
+ <Button {...props} variant="ghost" size="icon" disabled={busy}><EllipsisIcon class="size-4" /></Button>
146
+ {/snippet}
147
+ </DropdownMenu.Trigger>
148
+ <DropdownMenu.Content align="end">
149
+ <DropdownMenu.Item onSelect={() => revoke(r)}>{i18n.t.adminSessions.revoke}</DropdownMenu.Item>
150
+ <DropdownMenu.Item onSelect={() => revokeAll(r)}>{i18n.t.adminSessions.revokeAll}</DropdownMenu.Item>
151
+ </DropdownMenu.Content>
152
+ </DropdownMenu.Root>
153
+ </Table.Cell>
154
+ {/snippet}
155
+ </DataTable>
122
156
  </div>
@@ -0,0 +1,26 @@
1
+ import type { PageServerLoad } from "./$types";
2
+ import { requireAdmin } from "$lib/server/guards";
3
+
4
+ // Non-secret view of the DB-backed auth config (OAuth providers, SMTP, server
5
+ // toggles). Secrets are never sent — only a `hasSecret` flag per credential.
6
+ // `social` is dynamic: one entry per configured provider; `catalog` lists every
7
+ // provider that can be added.
8
+ export type SocialProviderView = { id: string; enabled: boolean; clientId: string; redirectURI: string; hasSecret: boolean };
9
+ export type AuthConfigView = {
10
+ social: Record<string, SocialProviderView>;
11
+ catalog: ReadonlyArray<{ id: string; label: string }>;
12
+ smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
13
+ server: { requireEmailVerification: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
14
+ };
15
+
16
+ export const load: PageServerLoad = async ({ locals, fetch }) => {
17
+ requireAdmin(locals.user);
18
+ let authConfig: AuthConfigView | null = null;
19
+ try {
20
+ const res = await fetch("/api/account/auth-config");
21
+ if (res.ok) authConfig = (await res.json()) as AuthConfigView;
22
+ } catch {
23
+ /* leave null — the page shows the DB-config sections as unavailable */
24
+ }
25
+ return { authConfig };
26
+ };