@podosoft/podokit 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/dist/add.d.ts +2 -0
- package/dist/add.js +5 -1
- package/dist/assemble.d.ts +14 -0
- package/dist/assemble.js +60 -0
- package/dist/create.d.ts +2 -0
- package/dist/create.js +7 -0
- package/dist/eject.d.ts +11 -0
- package/dist/eject.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -1
- package/dist/inspect.d.ts +43 -0
- package/dist/inspect.js +82 -0
- package/dist/lockfile.d.ts +80 -0
- package/dist/lockfile.js +193 -0
- package/dist/templates/base/dot-env.example +11 -6
- package/dist/templates/fullstack-nest-svelte/README.md +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
- package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
- package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
- package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
- package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
- package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
- package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
- package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
- package/dist/templates/modules/auth/module.manifest.json +54 -20
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
- package/dist/templates/modules/bullmq/module.manifest.json +4 -4
- package/dist/templates/modules/file-upload/module.manifest.json +3 -3
- package/dist/templates/modules/job-progress/module.manifest.json +9 -9
- package/dist/templates/modules/logging/module.manifest.json +3 -3
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
- package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
- package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
- package/dist/templates/modules/redis/module.manifest.json +4 -4
- package/dist/templates/modules/sse/module.manifest.json +4 -4
- package/dist/templates/todo/README.md +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
- package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/todo/apps/api/src/main.ts +1 -1
- package/dist/templates/todo/apps/web/package.json +2 -2
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/todo/dot-env.example +10 -5
- package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
- package/dist/templates/todo/tests/README.md +1 -1
- package/dist/templates/todo/tests/package.json +2 -1
- package/dist/templates/todo/tests/playwright.config.ts +1 -1
- package/dist/update.d.ts +49 -0
- package/dist/update.js +158 -0
- package/package.json +2 -2
|
@@ -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
|
|
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 = {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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: "",
|
|
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
|
-
|
|
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: "",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
<
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
313
|
-
|
|
438
|
+
<Button
|
|
439
|
+
variant="ghost"
|
|
314
440
|
onclick={() => (mSection = key)}
|
|
315
441
|
aria-current={mSection === key ? "page" : undefined}
|
|
316
|
-
class="
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
+
};
|