@podosoft/podokit 1.2.0 → 1.4.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.
- package/dist/add.d.ts +13 -2
- package/dist/add.js +135 -20
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -16,11 +16,18 @@ POSTGRES_DB=podokit
|
|
|
16
16
|
# queries, plus DB_AUTH_POOL_MAX for the auth module when it is installed. Keep
|
|
17
17
|
# replicas x (DB_POOL_MAX + DB_AUTH_POOL_MAX), plus a few for migrations, below
|
|
18
18
|
# the server's max_connections (100 by default). The timeouts are milliseconds.
|
|
19
|
+
# DB_QUERY_TIMEOUT_MS bounds how long a query waits for a server that stopped
|
|
20
|
+
# answering; migrations and maintenance commands are not bounded.
|
|
19
21
|
# DB_POOL_MAX=20
|
|
20
22
|
# DB_CONNECT_TIMEOUT_MS=10000
|
|
21
23
|
# DB_IDLE_TIMEOUT_MS=30000
|
|
24
|
+
# DB_QUERY_TIMEOUT_MS=10000
|
|
22
25
|
# DB_AUTH_POOL_MAX=10
|
|
23
26
|
|
|
27
|
+
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
|
+
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
|
+
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
30
|
+
|
|
24
31
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
25
32
|
# long a result is reused across probes (0 checks on every request).
|
|
26
33
|
# READINESS_CHECK_TIMEOUT_MS=800
|
|
@@ -44,3 +51,6 @@ REDIS_DB=0
|
|
|
44
51
|
# REDIS_USERNAME=
|
|
45
52
|
# REDIS_PASSWORD=
|
|
46
53
|
REDIS_TLS=false
|
|
54
|
+
# Longest wait in milliseconds for Redis to answer one command. A command that
|
|
55
|
+
# gets no reply in time fails, and its connection is closed and reopened.
|
|
56
|
+
# REDIS_COMMAND_TIMEOUT_MS=1000
|
|
@@ -20,4 +20,6 @@ branch tags, and empty tags, and so should you.
|
|
|
20
20
|
For a hand-managed API WebSocket endpoint, add a `pathType: Exact` Ingress path to
|
|
21
21
|
the API service before the existing `/` web path. Do not route a prefix or the root
|
|
22
22
|
to API. Deployment profiles express the same rule with
|
|
23
|
-
`exposure.webSocketPaths` and validate it before rendering.
|
|
23
|
+
`exposure.webSocketPaths` and validate it before rendering. A large-upload route
|
|
24
|
+
under `/api/` needs `/api` removed on the way to the API; `exposure.apiPaths`
|
|
25
|
+
renders a separate Ingress with a Traefik `stripPrefix` Middleware for that.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type {
|
|
4
|
+
import type { PlaywrightTestConfig } from "@playwright/test";
|
|
5
5
|
|
|
6
|
-
type Projects = NonNullable<
|
|
6
|
+
type Projects = NonNullable<PlaywrightTestConfig["projects"]>;
|
|
7
7
|
|
|
8
8
|
const requireFromTests = createRequire(__filename);
|
|
9
9
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
4
5
|
|
|
6
|
+
const compiledRuntime = new URL("../dist/bootstrap-admin-runtime.js", import.meta.url);
|
|
7
|
+
const sourceRuntime = new URL("../src/bootstrap-admin-runtime.ts", import.meta.url);
|
|
8
|
+
|
|
5
9
|
const args = process.argv.slice(2);
|
|
6
10
|
|
|
7
11
|
function has(flag) {
|
|
@@ -130,34 +134,15 @@ async function main() {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
const input = resolveBootstrapInput();
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const { auth } =
|
|
137
|
-
const { pool } = await import("../dist/auth/db.js");
|
|
138
|
-
const { verifyPassword } = await import("better-auth/crypto");
|
|
137
|
+
// A production image runs the bundled entry; development runs the source.
|
|
138
|
+
const useCompiled = process.env.NODE_ENV === "production" && existsSync(compiledRuntime);
|
|
139
|
+
const runtime = await import((useCompiled ? compiledRuntime : sourceRuntime).href);
|
|
140
|
+
const { auth, findBootstrapUser, verifyPassword, closeAuthDatabase } = runtime;
|
|
139
141
|
|
|
140
142
|
try {
|
|
141
143
|
const result = await bootstrapAdmin(
|
|
142
144
|
{
|
|
143
|
-
findUser:
|
|
144
|
-
const query = await pool.query(
|
|
145
|
-
`SELECT u.id,
|
|
146
|
-
u.email,
|
|
147
|
-
u.role,
|
|
148
|
-
u."emailVerified" AS "emailVerified",
|
|
149
|
-
u."signupApproved" AS "signupApproved",
|
|
150
|
-
a.password AS "passwordHash"
|
|
151
|
-
FROM "user" AS u
|
|
152
|
-
LEFT JOIN "account" AS a
|
|
153
|
-
ON a."userId" = u.id
|
|
154
|
-
AND a."providerId" = 'credential'
|
|
155
|
-
WHERE lower(u.email) = lower($1)
|
|
156
|
-
LIMIT 1`,
|
|
157
|
-
[email],
|
|
158
|
-
);
|
|
159
|
-
return query.rows[0] ?? null;
|
|
160
|
-
},
|
|
145
|
+
findUser: findBootstrapUser,
|
|
161
146
|
createUser: async (user) => auth.api.createUser({ body: user }),
|
|
162
147
|
verifyPassword: async (hash, password) => verifyPassword({ hash, password }),
|
|
163
148
|
},
|
|
@@ -174,14 +159,24 @@ async function main() {
|
|
|
174
159
|
console.log(`Verified existing administrator account ${result.email}.`);
|
|
175
160
|
}
|
|
176
161
|
} finally {
|
|
177
|
-
await
|
|
162
|
+
await closeAuthDatabase();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A refused PostgreSQL connection is an AggregateError with an empty message.
|
|
167
|
+
export function errorMessage(error) {
|
|
168
|
+
if (!(error instanceof Error)) return String(error);
|
|
169
|
+
if (error.message) return error.message;
|
|
170
|
+
if (error instanceof AggregateError && error.errors.length > 0) {
|
|
171
|
+
return error.errors.map(errorMessage).join("; ");
|
|
178
172
|
}
|
|
173
|
+
return error.name;
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
182
177
|
if (invokedPath === import.meta.url) {
|
|
183
178
|
main().catch((error) => {
|
|
184
|
-
console.error(
|
|
179
|
+
console.error(errorMessage(error));
|
|
185
180
|
process.exitCode = 1;
|
|
186
181
|
});
|
|
187
182
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import type { QueryRunner } from "typeorm";
|
|
4
|
+
import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
|
|
5
|
+
import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
|
|
6
|
+
|
|
7
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[], invalid = false): QueryRunner {
|
|
8
|
+
return {
|
|
9
|
+
connection: { options: { type } },
|
|
10
|
+
query: (sql: string): Promise<unknown[]> => {
|
|
11
|
+
queries.push(sql);
|
|
12
|
+
return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
|
|
13
|
+
},
|
|
14
|
+
} as unknown as QueryRunner;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Finalized at once: an open statement keeps SQLite from dropping an index.
|
|
18
|
+
function rows<Row>(database: Database, sql: string): Row[] {
|
|
19
|
+
const statement = database.prepare<Row, []>(sql);
|
|
20
|
+
try {
|
|
21
|
+
return statement.all();
|
|
22
|
+
} finally {
|
|
23
|
+
statement.finalize();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
|
|
28
|
+
|
|
29
|
+
describe("admin session list index migration", () => {
|
|
30
|
+
it("builds the PostgreSQL index concurrently outside a transaction", async () => {
|
|
31
|
+
const migration = new IndexSessionCreatedAt1792060000000();
|
|
32
|
+
expect(migration.transaction).toBe(false);
|
|
33
|
+
const up: string[] = [];
|
|
34
|
+
await migration.up(runner("postgres", up));
|
|
35
|
+
expect(up).toEqual([
|
|
36
|
+
"SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
|
|
37
|
+
CREATE,
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const down: string[] = [];
|
|
41
|
+
await migration.down(runner("postgres", down));
|
|
42
|
+
expect(down).toEqual(['DROP INDEX IF EXISTS "IDX_session_createdAt_id"']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rebuilds an invalid index left by an interrupted build", async () => {
|
|
46
|
+
const up: string[] = [];
|
|
47
|
+
await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
|
|
48
|
+
expect(up.slice(1)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("serves the newest-first session page on SQLite and reverts", async () => {
|
|
52
|
+
const database = new Database(":memory:");
|
|
53
|
+
try {
|
|
54
|
+
database.run(`CREATE TABLE "session" (
|
|
55
|
+
"id" text PRIMARY KEY, "userId" text NOT NULL, "token" text NOT NULL,
|
|
56
|
+
"expiresAt" date NOT NULL, "createdAt" date NOT NULL, "updatedAt" date NOT NULL
|
|
57
|
+
)`);
|
|
58
|
+
const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
|
|
59
|
+
await runSqliteMigrations(database, migrations);
|
|
60
|
+
const plan = rows<{ detail: string }>(
|
|
61
|
+
database,
|
|
62
|
+
`EXPLAIN QUERY PLAN SELECT "id" FROM "session" WHERE "expiresAt" > '2026' ORDER BY "createdAt" DESC, "id" DESC LIMIT 16`,
|
|
63
|
+
).map((row) => row.detail).join("\n");
|
|
64
|
+
expect(plan).toContain("IDX_session_createdAt_id");
|
|
65
|
+
expect(plan).not.toContain("TEMP B-TREE");
|
|
66
|
+
|
|
67
|
+
expect(await revertLatestSqliteMigration(database, migrations)).toBe(true);
|
|
68
|
+
expect(rows(database, `SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "name" LIKE 'IDX_%'`))
|
|
69
|
+
.toEqual([]);
|
|
70
|
+
} finally {
|
|
71
|
+
database.close();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
6
|
+
import { adminUserRoutes } from "./admin-users.routes";
|
|
7
|
+
import { AdminUsersService } from "./admin-users.service";
|
|
8
|
+
|
|
9
|
+
// Callers identify themselves with a test header; only "admin" passes.
|
|
10
|
+
const auth: Pick<AuthService, "requireAdmin"> = {
|
|
11
|
+
requireAdmin: async (request: Request): Promise<AuthSession> => {
|
|
12
|
+
const role = request.headers.get("x-test-role");
|
|
13
|
+
if (!role) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
14
|
+
if (role !== "admin") throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
|
|
15
|
+
return { user: { id: "admin-1", role: "admin" }, session: {} };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("admin user routes", () => {
|
|
20
|
+
let db: SQL;
|
|
21
|
+
let handle: (path: string, role?: string) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
db = new SQL(":memory:");
|
|
25
|
+
await db`
|
|
26
|
+
CREATE TABLE "user" (
|
|
27
|
+
"id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
|
|
28
|
+
"emailVerified" INTEGER NOT NULL, "createdAt" DATE NOT NULL, "role" TEXT,
|
|
29
|
+
"banned" INTEGER, "banReason" TEXT, "banExpires" DATE, "signupApproved" INTEGER
|
|
30
|
+
)
|
|
31
|
+
`;
|
|
32
|
+
await db`
|
|
33
|
+
INSERT INTO "user" VALUES
|
|
34
|
+
('u1', 'Ada', 'ada@example.com', 1, '2026-01-01T00:00:01.000Z', 'admin', 0, NULL, NULL, 1),
|
|
35
|
+
('u2', 'Bob', 'bob@example.com', 1, '2026-01-01T00:00:02.000Z', 'admin', 1, 'spam', NULL, 1)
|
|
36
|
+
`;
|
|
37
|
+
const app = new Elysia()
|
|
38
|
+
.onError(({ code, error, set }) => {
|
|
39
|
+
if (error instanceof AppException) {
|
|
40
|
+
set.status = error.statusCode;
|
|
41
|
+
return { code: error.code };
|
|
42
|
+
}
|
|
43
|
+
if (code === "VALIDATION") {
|
|
44
|
+
set.status = 400;
|
|
45
|
+
return { code: "VALIDATION_ERROR" };
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
})
|
|
49
|
+
.use(adminUserRoutes({ auth, users: new AdminUsersService(db, "sqlite") }));
|
|
50
|
+
handle = (path, role) =>
|
|
51
|
+
app.handle(new Request(`http://localhost${path}`, { headers: role ? { "x-test-role": role } : {} }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await db.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("requires an administrator", async () => {
|
|
59
|
+
expect((await handle("/admin/users")).status).toBe(401);
|
|
60
|
+
expect((await handle("/admin/users", "user")).status).toBe(403);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("applies role and status together", async () => {
|
|
64
|
+
const response = await handle("/admin/users?role=admin&status=active&limit=10&offset=0", "admin");
|
|
65
|
+
expect(response.status).toBe(200);
|
|
66
|
+
const page = await response.json() as { items: Array<{ id: string; banned: boolean }>; total: number };
|
|
67
|
+
expect(page.items).toEqual([expect.objectContaining({ id: "u1", banned: false })]);
|
|
68
|
+
expect(page.total).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("rejects unknown sort fields, statuses, and out-of-range limits", async () => {
|
|
72
|
+
for (const query of ["sortBy=password", "status=deleted", "sortDirection=up", "limit=0", "limit=101", "offset=-1", "searchField=role"]) {
|
|
73
|
+
expect((await handle(`/admin/users?${query}`, "admin")).status, query).toBe(400);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Elysia, t } from "elysia";
|
|
2
|
+
import type { AuthService } from "../auth/auth.service";
|
|
3
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
4
|
+
import {
|
|
5
|
+
ADMIN_USER_SORT_FIELDS,
|
|
6
|
+
ADMIN_USER_STATUSES,
|
|
7
|
+
ADMIN_USERS_MAX_LIMIT,
|
|
8
|
+
type AdminUsersService,
|
|
9
|
+
} from "./admin-users.service";
|
|
10
|
+
|
|
11
|
+
export interface AdminUserRoutesOptions {
|
|
12
|
+
auth: Pick<AuthService, "requireAdmin">;
|
|
13
|
+
users: AdminUsersService;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function adminUserRoutes({ auth, users }: AdminUserRoutesOptions): ElysiaPlugin {
|
|
17
|
+
return new Elysia({ name: "podokit.admin.users" })
|
|
18
|
+
.get("/admin/users", async ({ request, query }) => {
|
|
19
|
+
await auth.requireAdmin(request);
|
|
20
|
+
return users.list(query);
|
|
21
|
+
}, {
|
|
22
|
+
query: t.Object({
|
|
23
|
+
limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_USERS_MAX_LIMIT })),
|
|
24
|
+
offset: t.Optional(t.Numeric({ minimum: 0 })),
|
|
25
|
+
search: t.Optional(t.String({ maxLength: 200 })),
|
|
26
|
+
searchField: t.Optional(t.Union([t.Literal("email"), t.Literal("name")])),
|
|
27
|
+
role: t.Optional(t.String({ minLength: 1, maxLength: 64 })),
|
|
28
|
+
status: t.Optional(t.Union(ADMIN_USER_STATUSES.map((status) => t.Literal(status)))),
|
|
29
|
+
sortBy: t.Optional(t.Union(ADMIN_USER_SORT_FIELDS.map((field) => t.Literal(field)))),
|
|
30
|
+
sortDirection: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])),
|
|
31
|
+
}),
|
|
32
|
+
detail: {
|
|
33
|
+
tags: ["admin"],
|
|
34
|
+
summary: "List users",
|
|
35
|
+
description:
|
|
36
|
+
"Administrators only. Offset-paged with `limit` and `offset`; newest first unless " +
|
|
37
|
+
"`sortBy`/`sortDirection` say otherwise. `search` matches `searchField` (email by " +
|
|
38
|
+
"default) case-insensitively. `role` and `status` (active, pending, banned) combine.",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { AdminUsersService, type AdminUserQuery } from "./admin-users.service";
|
|
4
|
+
|
|
5
|
+
interface SeedUser {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
email: string;
|
|
9
|
+
role?: string | null;
|
|
10
|
+
banned?: boolean | null;
|
|
11
|
+
signupApproved?: boolean | null;
|
|
12
|
+
emailVerified?: boolean;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Better Auth stores booleans as 0 or 1 and dates as ISO text on SQLite.
|
|
17
|
+
async function createSchema(db: SQL): Promise<void> {
|
|
18
|
+
await db`
|
|
19
|
+
CREATE TABLE "user" (
|
|
20
|
+
"id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
|
|
21
|
+
"emailVerified" INTEGER NOT NULL, "image" TEXT, "createdAt" DATE NOT NULL,
|
|
22
|
+
"updatedAt" DATE NOT NULL, "role" TEXT, "banned" INTEGER, "banReason" TEXT,
|
|
23
|
+
"banExpires" DATE, "signupApproved" INTEGER
|
|
24
|
+
)
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function flag(value: boolean | null | undefined): number | null {
|
|
29
|
+
return value === undefined || value === null ? null : value ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function seed(db: SQL, users: SeedUser[]): Promise<void> {
|
|
33
|
+
for (const user of users) {
|
|
34
|
+
await db`
|
|
35
|
+
INSERT INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt",
|
|
36
|
+
"role", "banned", "banReason", "signupApproved")
|
|
37
|
+
VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified ? 1 : 0},
|
|
38
|
+
${user.createdAt}, ${user.createdAt}, ${user.role ?? null}, ${flag(user.banned)},
|
|
39
|
+
${user.banned ? "spam" : null}, ${flag(user.signupApproved)})
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const at = (minute: number): string => `2026-01-01T00:${String(minute).padStart(2, "0")}:00.000Z`;
|
|
45
|
+
|
|
46
|
+
const USERS: SeedUser[] = [
|
|
47
|
+
{ id: "u1", name: "Ada Admin", email: "ada@example.com", role: "admin", signupApproved: true, createdAt: at(1) },
|
|
48
|
+
{ id: "u2", name: "Bob", email: "bob@example.com", role: "user", signupApproved: true, createdAt: at(2) },
|
|
49
|
+
{ id: "u3", name: "Cy Banned", email: "cy@example.com", role: "user", banned: true, signupApproved: true, createdAt: at(3) },
|
|
50
|
+
{ id: "u4", name: "Di Pending", email: "di@example.com", role: "user", signupApproved: false, createdAt: at(4) },
|
|
51
|
+
{ id: "u5", name: "Eve Banned Admin", email: "eve@example.com", role: "admin,user", banned: true, createdAt: at(5) },
|
|
52
|
+
{ id: "u6", name: "Fay Legacy", email: "fay@example.com", role: null, banned: null, signupApproved: null, createdAt: at(6) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
describe("AdminUsersService", () => {
|
|
56
|
+
let db: SQL;
|
|
57
|
+
let service: AdminUsersService;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
db = new SQL(":memory:");
|
|
61
|
+
await createSchema(db);
|
|
62
|
+
await seed(db, USERS);
|
|
63
|
+
service = new AdminUsersService(db, "sqlite");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await db.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ids = async (query: AdminUserQuery): Promise<string[]> =>
|
|
71
|
+
(await service.list(query)).items.map((user) => user.id);
|
|
72
|
+
|
|
73
|
+
test("pages newest first by default with a total", async () => {
|
|
74
|
+
const page = await service.list({ limit: 2, offset: 2 });
|
|
75
|
+
expect(page.items.map((user) => user.id)).toEqual(["u4", "u3"]);
|
|
76
|
+
expect(page.total).toBe(6);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("combines the role and status filters", async () => {
|
|
80
|
+
expect(await ids({ role: "admin", status: "banned" })).toEqual(["u5"]);
|
|
81
|
+
expect(await ids({ role: "admin", status: "active" })).toEqual(["u1"]);
|
|
82
|
+
expect(await ids({ role: "user", status: "pending" })).toEqual(["u4"]);
|
|
83
|
+
expect((await service.list({ role: "user", status: "active" })).total).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("treats unset approval and ban flags as an active user", async () => {
|
|
87
|
+
expect(await ids({ status: "active" })).toEqual(["u6", "u2", "u1"]);
|
|
88
|
+
expect(await ids({ status: "banned" })).toEqual(["u5", "u3"]);
|
|
89
|
+
expect(await ids({ status: "pending" })).toEqual(["u4"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("matches one role in a comma-separated role list", async () => {
|
|
93
|
+
expect(await ids({ role: "user" })).toEqual(["u5", "u4", "u3", "u2"]);
|
|
94
|
+
expect(await ids({ role: "use" })).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("searches email or name case-insensitively with literal wildcards", async () => {
|
|
98
|
+
expect(await ids({ search: "ADA@EXAMPLE", searchField: "email" })).toEqual(["u1"]);
|
|
99
|
+
expect(await ids({ search: "banned", searchField: "name" })).toEqual(["u5", "u3"]);
|
|
100
|
+
expect(await ids({ search: "%", searchField: "name" })).toEqual([]);
|
|
101
|
+
expect(await ids({ search: "banned", searchField: "name", status: "banned", role: "admin" })).toEqual(["u5"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("sorts by an allowed column with the id as a tiebreaker", async () => {
|
|
105
|
+
expect(await ids({ sortBy: "name", sortDirection: "asc", limit: 3 })).toEqual(["u1", "u2", "u3"]);
|
|
106
|
+
expect(await ids({ sortBy: "email", sortDirection: "desc", limit: 2 })).toEqual(["u6", "u5"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns booleans and ISO dates without exposing other columns", async () => {
|
|
110
|
+
const [pending] = (await service.list({ status: "pending" })).items;
|
|
111
|
+
expect(pending).toEqual({
|
|
112
|
+
id: "u4",
|
|
113
|
+
name: "Di Pending",
|
|
114
|
+
email: "di@example.com",
|
|
115
|
+
role: "user",
|
|
116
|
+
emailVerified: false,
|
|
117
|
+
signupApproved: false,
|
|
118
|
+
banned: false,
|
|
119
|
+
banReason: null,
|
|
120
|
+
banExpires: null,
|
|
121
|
+
createdAt: at(4),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
|
|
4
|
+
export const ADMIN_USERS_DEFAULT_LIMIT = 15;
|
|
5
|
+
export const ADMIN_USERS_MAX_LIMIT = 100;
|
|
6
|
+
export const ADMIN_USER_SORT_FIELDS = ["createdAt", "name", "email", "role"] as const;
|
|
7
|
+
export const ADMIN_USER_STATUSES = ["active", "pending", "banned"] as const;
|
|
8
|
+
|
|
9
|
+
export type AdminUserSortField = (typeof ADMIN_USER_SORT_FIELDS)[number];
|
|
10
|
+
export type AdminUserStatus = (typeof ADMIN_USER_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
/** A user as the admin list shows it. Credentials and sessions stay on the server. */
|
|
13
|
+
export interface AdminUserView {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
role: string | null;
|
|
18
|
+
emailVerified: boolean;
|
|
19
|
+
signupApproved: boolean;
|
|
20
|
+
banned: boolean;
|
|
21
|
+
banReason: string | null;
|
|
22
|
+
banExpires: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdminUserPage {
|
|
27
|
+
items: AdminUserView[];
|
|
28
|
+
total: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AdminUserQuery {
|
|
32
|
+
limit?: number;
|
|
33
|
+
offset?: number;
|
|
34
|
+
search?: string;
|
|
35
|
+
searchField?: "email" | "name";
|
|
36
|
+
role?: string;
|
|
37
|
+
status?: AdminUserStatus;
|
|
38
|
+
sortBy?: AdminUserSortField;
|
|
39
|
+
sortDirection?: "asc" | "desc";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Flag = boolean | number | null;
|
|
43
|
+
|
|
44
|
+
interface UserRow {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
email: string;
|
|
48
|
+
role: string | null;
|
|
49
|
+
emailVerified: Flag;
|
|
50
|
+
signupApproved: Flag;
|
|
51
|
+
banned: Flag;
|
|
52
|
+
banReason: string | null;
|
|
53
|
+
banExpires: Date | string | null;
|
|
54
|
+
createdAt: Date | string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SQLite stores booleans as 0 or 1. An unset approval predates sign-up approval.
|
|
58
|
+
function flag(value: Flag, unset: boolean): boolean {
|
|
59
|
+
return value === null ? unset : value === true || value === 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function iso(value: Date | string): string {
|
|
63
|
+
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function likePattern(search: string): string {
|
|
67
|
+
return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lists users for the admin console with every filter applied in one query.
|
|
72
|
+
* Better Auth `listUsers` accepts a single filter, so role and status could not
|
|
73
|
+
* combine there. Mutations stay on Better Auth so its permission checks and the
|
|
74
|
+
* audit hooks still run.
|
|
75
|
+
*/
|
|
76
|
+
export class AdminUsersService {
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly sql: SQL,
|
|
79
|
+
private readonly provider: DatabaseProviderName,
|
|
80
|
+
) {}
|
|
81
|
+
|
|
82
|
+
async list(query: AdminUserQuery = {}): Promise<AdminUserPage> {
|
|
83
|
+
const limit = Math.min(Math.max(query.limit ?? ADMIN_USERS_DEFAULT_LIMIT, 1), ADMIN_USERS_MAX_LIMIT);
|
|
84
|
+
const offset = Math.max(query.offset ?? 0, 0);
|
|
85
|
+
const search = query.search?.trim() ?? "";
|
|
86
|
+
const filters = (): SQL.Query<unknown> => this.sql`
|
|
87
|
+
WHERE 1 = 1
|
|
88
|
+
${search ? this.searchFilter(query.searchField ?? "email", likePattern(search)) : this.sql``}
|
|
89
|
+
${query.role ? this.sql`AND (',' || u."role" || ',') LIKE ${`%,${query.role},%`}` : this.sql``}
|
|
90
|
+
${query.status ? this.statusFilter(query.status) : this.sql``}
|
|
91
|
+
`;
|
|
92
|
+
const [rows, totals] = await Promise.all([
|
|
93
|
+
this.sql<UserRow[]>`
|
|
94
|
+
SELECT u."id", u."name", u."email", u."role", u."emailVerified", u."signupApproved",
|
|
95
|
+
u."banned", u."banReason", u."banExpires", u."createdAt"
|
|
96
|
+
FROM "user" u
|
|
97
|
+
${filters()}
|
|
98
|
+
${this.order(query.sortBy ?? "createdAt", query.sortDirection ?? "desc")}
|
|
99
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
100
|
+
`,
|
|
101
|
+
this.sql<Array<{ total: number | string | bigint }>>`
|
|
102
|
+
SELECT COUNT(*) AS "total" FROM "user" u ${filters()}
|
|
103
|
+
`,
|
|
104
|
+
]);
|
|
105
|
+
return {
|
|
106
|
+
items: rows.map((row) => ({
|
|
107
|
+
id: row.id,
|
|
108
|
+
name: row.name,
|
|
109
|
+
email: row.email,
|
|
110
|
+
role: row.role,
|
|
111
|
+
emailVerified: flag(row.emailVerified, false),
|
|
112
|
+
signupApproved: flag(row.signupApproved, true),
|
|
113
|
+
banned: flag(row.banned, false),
|
|
114
|
+
banReason: row.banReason,
|
|
115
|
+
banExpires: row.banExpires === null ? null : iso(row.banExpires),
|
|
116
|
+
createdAt: iso(row.createdAt),
|
|
117
|
+
})),
|
|
118
|
+
total: Number(totals[0]?.total ?? 0),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private searchFilter(field: "email" | "name", pattern: string): SQL.Query<unknown> {
|
|
123
|
+
const column = field === "name" ? this.sql`u."name"` : this.sql`u."email"`;
|
|
124
|
+
// SQLite LIKE ignores ASCII case; PostgreSQL needs ILIKE.
|
|
125
|
+
return this.provider === "sqlite"
|
|
126
|
+
? this.sql`AND ${column} LIKE ${pattern} ESCAPE '\\'`
|
|
127
|
+
: this.sql`AND ${column} ILIKE ${pattern} ESCAPE '\\'`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private statusFilter(status: AdminUserStatus): SQL.Query<unknown> {
|
|
131
|
+
switch (status) {
|
|
132
|
+
case "pending":
|
|
133
|
+
return this.sql`AND u."signupApproved" = FALSE`;
|
|
134
|
+
case "banned":
|
|
135
|
+
return this.sql`AND u."banned" = TRUE`;
|
|
136
|
+
case "active":
|
|
137
|
+
return this.sql`AND COALESCE(u."banned", FALSE) = FALSE AND COALESCE(u."signupApproved", TRUE) = TRUE`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private order(field: AdminUserSortField, direction: "asc" | "desc"): SQL.Query<unknown> {
|
|
142
|
+
const column = {
|
|
143
|
+
createdAt: this.sql`u."createdAt"`,
|
|
144
|
+
name: this.sql`u."name"`,
|
|
145
|
+
email: this.sql`u."email"`,
|
|
146
|
+
role: this.sql`u."role"`,
|
|
147
|
+
}[field];
|
|
148
|
+
// The id breaks ties so offset pages neither skip nor repeat users.
|
|
149
|
+
return direction === "asc"
|
|
150
|
+
? this.sql`ORDER BY ${column} ASC, u."id" ASC`
|
|
151
|
+
: this.sql`ORDER BY ${column} DESC, u."id" DESC`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -8,6 +8,8 @@ import { Elysia, t } from "elysia";
|
|
|
8
8
|
import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
|
|
9
9
|
import { createSessionRevoker, type RevokeUserSessionApi } from "./admin-sessions/admin-sessions.revoker";
|
|
10
10
|
import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
|
|
11
|
+
import { adminUserRoutes } from "./admin-users/admin-users.routes";
|
|
12
|
+
import { AdminUsersService } from "./admin-users/admin-users.service";
|
|
11
13
|
import { AUTH } from "./auth/auth.module";
|
|
12
14
|
import { getAuth } from "./auth/auth-provider";
|
|
13
15
|
import {
|
|
@@ -30,6 +32,7 @@ import { validateSiteSetting } from "./site-settings/site-settings.validation";
|
|
|
30
32
|
export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
|
|
31
33
|
export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
|
|
32
34
|
export const ADMIN_SESSIONS = Symbol("admin-sessions") as ServiceKey<AdminSessionsService>;
|
|
35
|
+
export const ADMIN_USERS = Symbol("admin-users") as ServiceKey<AdminUsersService>;
|
|
33
36
|
|
|
34
37
|
const ICON_TYPES = new Set([
|
|
35
38
|
"image/svg+xml",
|
|
@@ -54,6 +57,7 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
54
57
|
sessions: services.resolve(ADMIN_SESSIONS),
|
|
55
58
|
revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
|
|
56
59
|
}))
|
|
60
|
+
.use(adminUserRoutes({ auth, users: services.resolve(ADMIN_USERS) }))
|
|
57
61
|
.use(siteAssetRoutes(site, profileImages))
|
|
58
62
|
.get("/site/settings", async () => {
|
|
59
63
|
// The favicon fields are site.* rows too, so one read serves the response.
|
|
@@ -127,6 +131,7 @@ export const adminModule: PodokitModule = {
|
|
|
127
131
|
configure: (_env, services): void => {
|
|
128
132
|
const database = services.resolve(DATABASE);
|
|
129
133
|
services.register(ADMIN_SESSIONS, new AdminSessionsService(database.sql, database.provider));
|
|
134
|
+
services.register(ADMIN_USERS, new AdminUsersService(database.sql, database.provider));
|
|
130
135
|
const storage = services.resolve(OBJECT_STORAGE);
|
|
131
136
|
const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
|
|
132
137
|
const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
|