add registration queue

AuthorKonata <konata@posteo.jp>
Date
Commit1b85210b42c6297efee346371448ad67dd71d23d
Parent74acb47
11 files changed, 423 insertions(+), 51 deletions(-)
MREADME.md
@@ -6,15 +6,15 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
66
77 ## Features
88
9-- **Repository browser** — file tree, directly edit single files, blob view, commit log, README rendering, media previews
9+- **Repository browser** — file tree, directly edit single files, blob view, commit log, markdown rendering, media previews
1010 - **Issues** — create, comment, react
1111 - **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository.
1212 - **Templates** — issue/patch templates
1313 - **Releases** — releases with source archives, extra uploaded assets, and optional tag creation
1414 - **SSH push/pull** — built-in SSH server, no external git daemon needed
1515 - **Auth** — password login or passkeys (WebAuthn/FIDO2)
16-- **Commit signing** — merged patches automatically signed and verification badges are shown in the commit list view
17-- **Optional registration** — others can create accounts to file issues and patches; can be disabled
16+- **Commit signing** — merged patches and filed edited through the UI are automatically signed and verification badges are shown in the commit list view
17+- **Optional registration** — others can create accounts to file issues and patches; can be disabled, or put in queue mode where the admin manually approves new accounts.
1818
1919 ## Stack
2020
@@ -51,24 +51,25 @@ podman compose up
5151
5252 All settings are environment variables:
5353
54-| Variable | Default | Description |
55-|-------------------------|----------------------------------|-------------------------------------------------|
56-| `PORT` | `3000` | HTTP port |
57-| `SSH_PORT` | `2222` | SSH port |
58-| `DATA_DIR` | `./data` | Repos, database, uploads |
59-| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
60-| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
61-| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
62-| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
63-| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
64-| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
65-| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
66-| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
67-| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
68-| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
69-| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
70-| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches |
71-| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | Git committer email used when merging patches |
54+| Variable | Default | Description |
55+|-------------------------|----------------------------------|--------------------------------------------------------------------------------|
56+| `PORT` | `3000` | HTTP port |
57+| `SSH_PORT` | `2222` | SSH port |
58+| `DATA_DIR` | `./data` | Repos, database, uploads |
59+| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
60+| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
61+| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
62+| `REGISTRATION_TYPE` | `enabled` | Registration mode: `enabled`, `disabled`, or `queue` (requires admin approval) |
63+| `REGISTER_QUESTION` | _(empty)_ | Question shown on the registration form when `REGISTRATION_TYPE=queue` |
64+| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
65+| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
66+| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
67+| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
68+| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
69+| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
70+| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
71+| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches or editing files through the UI |
72+| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | same as above but for email |
7273
7374 \* More workers mean more CPU cores can be used to parallelize highlighting of files.
7475 Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -89,11 +90,10 @@ Pushing is also supported for the admin.
8990 bun run dev # watch mode
9091 bun run lint # Biome lint
9192 bun run format # Biome format
92-bun run test # Run E2E and unit tests (don't use bun test, it doesn't respect the timeout)
93+bun run test # Run E2E and unit tests (don't use bun test directly, it doesn't respect the timeout)
9394 ```
9495
9596 ## Roadmap
9697 - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
9798 - Issue labels
98-- redirect image urls in readme
99-- registration queue
99+- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
Mcompose.yml
@@ -17,4 +17,5 @@ services:
1717 # SSH_DISABLED: 0
1818 # ARCHIVE_ZST_ENABLED: 0
1919 # TRUSTED_PROXY: 1
20- # REGISTRATION_DISABLED: 0
20+ # REGISTRATION_TYPE: enabled
21+ # REGISTER_QUESTION: ""
Msrc/config.ts
@@ -13,7 +13,11 @@ export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
1313 export const SSH_DISABLED = !!process.env.SSH_DISABLED;
1414 export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
1515 export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
16-export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED;
16+export const REGISTRATION_TYPE = (process.env.REGISTRATION_TYPE ?? "enabled") as
17+ | "enabled"
18+ | "disabled"
19+ | "queue";
20+export const REGISTER_QUESTION = process.env.REGISTER_QUESTION ?? "";
1721 export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
1822 export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
1923 export const HIGHLIGHT_WORKERS =
Msrc/db/index.ts
@@ -10,6 +10,8 @@ interface UserTable {
1010 password_hash: string | null;
1111 created_at: string;
1212 avatar_version: Generated<number>;
13+ is_pending: Generated<number>;
14+ register_application: string | null;
1315 }
1416
1517 interface PasskeyTable {
@@ -169,6 +171,19 @@ const sqlite = new BunDatabase(DB_PATH);
169171 sqlite.run("PRAGMA journal_mode=WAL");
170172 sqlite.run("PRAGMA foreign_keys=ON");
171173
174+// Migration: add is_pending and register_application columns to users if missing
175+const userCols = sqlite
176+ .query<{ name: string }, []>("PRAGMA table_info(users)")
177+ .all();
178+if (!userCols.some((c) => c.name === "is_pending")) {
179+ sqlite.run(
180+ "ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
181+ );
182+}
183+if (!userCols.some((c) => c.name === "register_application")) {
184+ sqlite.run("ALTER TABLE users ADD COLUMN register_application TEXT");
185+}
186+
172187 // Migration: add version column if missing, then populate any empty values
173188 const patchCols = sqlite
174189 .query<{ name: string }, []>("PRAGMA table_info(patches)")
Msrc/db/schema.sql
@@ -1,11 +1,13 @@
11 CREATE TABLE IF NOT EXISTS users (
2- id INTEGER PRIMARY KEY AUTOINCREMENT,
3- username TEXT UNIQUE NOT NULL,
4- password_hash TEXT,
5- created_at TEXT NOT NULL,
6- avatar_version INTEGER NOT NULL DEFAULT 1,
7- git_name TEXT,
8- git_email TEXT
2+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3+ username TEXT UNIQUE NOT NULL,
4+ password_hash TEXT,
5+ created_at TEXT NOT NULL,
6+ avatar_version INTEGER NOT NULL DEFAULT 1,
7+ git_name TEXT,
8+ git_email TEXT,
9+ is_pending INTEGER NOT NULL DEFAULT 0,
10+ register_application TEXT
911 );
1012
1113 CREATE TABLE IF NOT EXISTS passkeys (
Msrc/middleware/session.ts
@@ -24,6 +24,7 @@ export async function resolveSession(
2424 ])
2525 .where("sessions.id", "=", cookie)
2626 .where("sessions.expires_at", ">", now)
27+ .where("users.is_pending", "=", 0)
2728 .executeTakeFirst();
2829 if (!session) return null;
2930 return {
Msrc/routes/auth.tsx
@@ -6,7 +6,7 @@ import {
66 } from "@simplewebauthn/server";
77 import * as argon2 from "argon2";
88 import { Elysia, t } from "elysia";
9-import { REGISTRATION_DISABLED } from "../config.ts";
9+import { REGISTER_QUESTION, REGISTRATION_TYPE } from "../config.ts";
1010 import {
1111 ADMIN_USERNAME,
1212 CHALLENGE_TTL_MS,
@@ -115,6 +115,12 @@ export const authRoutes = new Elysia()
115115 return html(<Login error="Invalid username or password" />);
116116 }
117117
118+ if (user.is_pending) {
119+ return html(
120+ <Login error="Your account is awaiting approval." />,
121+ );
122+ }
123+
118124 const sessionId = await createSession(user.id);
119125 return redirect("/", sessionCookie(sessionId));
120126 },
@@ -124,51 +130,74 @@ export const authRoutes = new Elysia()
124130 )
125131
126132 .get("/register", () => {
127- if (REGISTRATION_DISABLED)
133+ if (REGISTRATION_TYPE === "disabled")
128134 return new Response("Registration is disabled", { status: 403 });
129- return html(<Register />);
135+ return html(<Register question={REGISTER_QUESTION} />);
130136 })
131137
132138 .post(
133139 "/register",
134140 async ({ body, request, server }) => {
135- if (REGISTRATION_DISABLED)
141+ if (REGISTRATION_TYPE === "disabled")
136142 return new Response("Registration is disabled", {
137143 status: 403,
138144 });
139145 const ip = getClientIp(request, server);
140146 if (!checkRateLimit(ip, 3, 60 * 60_000)) {
141147 return html(
142- <Register error="Too many registration attempts. Please try again later." />,
148+ <Register
149+ error="Too many registration attempts. Please try again later."
150+ question={REGISTER_QUESTION}
151+ />,
143152 );
144153 }
145- const { username, password, password2 } = body;
154+ const { username, password, password2, application } = body;
146155
147156 if (!VALID_USERNAME_RE.test(username)) {
148157 return html(
149- <Register error="Username may only contain letters, numbers, hyphens, and underscores" />,
158+ <Register
159+ error="Username may only contain letters, numbers, hyphens, and underscores"
160+ question={REGISTER_QUESTION}
161+ />,
150162 );
151163 }
152164 if (username === ADMIN_USERNAME) {
153- return html(<Register error="That username is reserved" />);
165+ return html(
166+ <Register
167+ error="That username is reserved"
168+ question={REGISTER_QUESTION}
169+ />,
170+ );
154171 }
155172
156173 if (!password?.trim()) {
157174 return html(
158- <Register error="Password is required (use the passkey button for passwordless registration)" />,
175+ <Register
176+ error="Password is required (use the passkey button for passwordless registration)"
177+ question={REGISTER_QUESTION}
178+ />,
159179 );
160180 }
161181 if (password !== password2) {
162- return html(<Register error="Passwords do not match" />);
182+ return html(
183+ <Register
184+ error="Passwords do not match"
185+ question={REGISTER_QUESTION}
186+ />,
187+ );
163188 }
164189 if (password.length < 8) {
165190 return html(
166- <Register error="Password must be at least 8 characters" />,
191+ <Register
192+ error="Password must be at least 8 characters"
193+ question={REGISTER_QUESTION}
194+ />,
167195 );
168196 }
169197
170198 const hash = await argon2.hash(password);
171199 const now = new Date().toISOString();
200+ const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
172201 let result: { id: number };
173202 try {
174203 result = await db
@@ -177,6 +206,8 @@ export const authRoutes = new Elysia()
177206 username,
178207 password_hash: hash,
179208 created_at: now,
209+ is_pending: isPending,
210+ register_application: application ?? null,
180211 })
181212 .returning("id")
182213 .executeTakeFirstOrThrow();
@@ -187,11 +218,22 @@ export const authRoutes = new Elysia()
187218 "UNIQUE constraint failed: users.username",
188219 )
189220 ) {
190- return html(<Register error="Username already taken" />);
221+ return html(
222+ <Register
223+ error="Username already taken"
224+ question={REGISTER_QUESTION}
225+ />,
226+ );
191227 }
192228 throw err;
193229 }
194230
231+ if (REGISTRATION_TYPE === "queue") {
232+ return html(
233+ <Register pending={true} question={REGISTER_QUESTION} />,
234+ );
235+ }
236+
195237 const sessionId = await createSession(result.id);
196238 return redirect("/", sessionCookie(sessionId));
197239 },
@@ -200,6 +242,7 @@ export const authRoutes = new Elysia()
200242 username: t.String(),
201243 password: t.Optional(t.String()),
202244 password2: t.Optional(t.String()),
245+ application: t.Optional(t.String()),
203246 }),
204247 },
205248 )
@@ -219,7 +262,13 @@ export const authRoutes = new Elysia()
219262 .post(
220263 "/auth/passkey/create-user",
221264 async ({ body }) => {
222- const { username } = body;
265+ if (REGISTRATION_TYPE === "disabled") {
266+ return new Response(
267+ JSON.stringify({ error: "Registration is disabled" }),
268+ { status: 400 },
269+ );
270+ }
271+ const { username, application } = body;
223272 if (!username || !VALID_USERNAME_RE.test(username)) {
224273 return new Response(
225274 JSON.stringify({ error: "Invalid username" }),
@@ -235,6 +284,7 @@ export const authRoutes = new Elysia()
235284 );
236285 }
237286 const now = new Date().toISOString();
287+ const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
238288 let result: { id: number };
239289 try {
240290 result = await db
@@ -243,6 +293,8 @@ export const authRoutes = new Elysia()
243293 username,
244294 password_hash: null,
245295 created_at: now,
296+ is_pending: isPending,
297+ register_application: application ?? null,
246298 })
247299 .returning("id")
248300 .executeTakeFirstOrThrow();
@@ -261,6 +313,13 @@ export const authRoutes = new Elysia()
261313 throw err;
262314 }
263315
316+ if (REGISTRATION_TYPE === "queue") {
317+ return new Response(
318+ JSON.stringify({ ok: true, pending: true }),
319+ { headers: { "Content-Type": "application/json" } },
320+ );
321+ }
322+
264323 const sessionId = await createSession(result.id);
265324 return new Response(JSON.stringify({ ok: true }), {
266325 headers: {
@@ -270,7 +329,10 @@ export const authRoutes = new Elysia()
270329 });
271330 },
272331 {
273- body: t.Object({ username: t.String() }),
332+ body: t.Object({
333+ username: t.String(),
334+ application: t.Optional(t.String()),
335+ }),
274336 },
275337 )
276338
Msrc/routes/settings.tsx
@@ -6,6 +6,7 @@ import {
66 VALID_KEY_TYPES,
77 VALID_USERNAME_RE,
88 } from "../constants.ts";
9+import { REGISTRATION_TYPE } from "../config.ts";
910 import { db } from "../db";
1011 import { redirect } from "../lib/redirect.ts";
1112 import { resolveSession } from "../middleware/session.ts";
@@ -53,6 +54,21 @@ export const settingsRoutes = new Elysia()
5354 const theme = (cookie.theme?.value as string | undefined) ?? "auto";
5455 const hasPassword = !!userRow?.password_hash;
5556
57+ const pendingUsers =
58+ user.isAdmin && REGISTRATION_TYPE === "queue"
59+ ? await db
60+ .selectFrom("users")
61+ .select([
62+ "id",
63+ "username",
64+ "register_application",
65+ "created_at",
66+ ])
67+ .where("is_pending", "=", 1)
68+ .orderBy("created_at", "desc")
69+ .execute()
70+ : [];
71+
5672 const decodedError = error
5773 ? decodeURIComponent((error as string).replace(/\+/g, " "))
5874 : null;
@@ -66,6 +82,7 @@ export const settingsRoutes = new Elysia()
6682 theme={theme}
6783 success={(success as string | null) ?? null}
6884 error={decodedError}
85+ pendingUsers={pendingUsers}
6986 />,
7087 );
7188 },
@@ -344,6 +361,77 @@ export const settingsRoutes = new Elysia()
344361 },
345362 )
346363
364+ .post(
365+ "/admin/users/approve",
366+ async ({ cookie, body }) => {
367+ const user = await resolveSession(
368+ cookie.session?.value as string | undefined,
369+ );
370+ if (!user) return redirect("/login");
371+ if (!user.isAdmin)
372+ return new Response("Forbidden", { status: 403 });
373+
374+ await db
375+ .updateTable("users")
376+ .set({ is_pending: 0 })
377+ .where("id", "=", body.id)
378+ .where("is_pending", "=", 1)
379+ .execute();
380+
381+ return redirect("/settings?success=user_approved");
382+ },
383+ { body: t.Object({ id: t.Numeric() }) },
384+ )
385+
386+ .post(
387+ "/admin/users/deny",
388+ async ({ cookie, body }) => {
389+ const user = await resolveSession(
390+ cookie.session?.value as string | undefined,
391+ );
392+ if (!user) return redirect("/login");
393+ if (!user.isAdmin)
394+ return new Response("Forbidden", { status: 403 });
395+
396+ await db
397+ .deleteFrom("users")
398+ .where("id", "=", body.id)
399+ .where("is_pending", "=", 1)
400+ .execute();
401+
402+ return redirect("/settings?success=user_denied");
403+ },
404+ { body: t.Object({ id: t.Numeric() }) },
405+ )
406+
407+ .post("/admin/users/approve-all", async ({ cookie }) => {
408+ const user = await resolveSession(
409+ cookie.session?.value as string | undefined,
410+ );
411+ if (!user) return redirect("/login");
412+ if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
413+
414+ await db
415+ .updateTable("users")
416+ .set({ is_pending: 0 })
417+ .where("is_pending", "=", 1)
418+ .execute();
419+
420+ return redirect("/settings?success=all_approved");
421+ })
422+
423+ .post("/admin/users/deny-all", async ({ cookie }) => {
424+ const user = await resolveSession(
425+ cookie.session?.value as string | undefined,
426+ );
427+ if (!user) return redirect("/login");
428+ if (!user.isAdmin) return new Response("Forbidden", { status: 403 });
429+
430+ await db.deleteFrom("users").where("is_pending", "=", 1).execute();
431+
432+ return redirect("/settings?success=all_denied");
433+ })
434+
347435 .post(
348436 "/settings/ssh-keys",
349437 async ({ cookie, body }) => {
Msrc/styles/main.css
@@ -2574,6 +2574,54 @@
25742574 flex: 1;
25752575 color: var(--color-text-muted);
25762576 }
2577+.queue-bulk-actions {
2578+ display: flex;
2579+ gap: var(--space-2);
2580+ margin-bottom: var(--space-4);
2581+}
2582+.queue-list {
2583+ list-style: none;
2584+ padding: 0;
2585+ margin: 0;
2586+ max-height: 32rem;
2587+ overflow-y: auto;
2588+}
2589+.queue-item {
2590+ display: flex;
2591+ align-items: flex-start;
2592+ justify-content: space-between;
2593+ gap: var(--space-4);
2594+ padding: var(--space-3) 0;
2595+ border-top: 1px solid var(--color-border-muted);
2596+ font-size: var(--text-sm);
2597+}
2598+.queue-item-meta {
2599+ display: flex;
2600+ flex-direction: column;
2601+ gap: var(--space-1);
2602+ min-width: 0;
2603+}
2604+.queue-item-header {
2605+ display: flex;
2606+ align-items: baseline;
2607+ gap: var(--space-2);
2608+}
2609+.queue-item-date {
2610+ color: var(--color-text-muted);
2611+ font-size: var(--text-xs);
2612+}
2613+.queue-item-answer {
2614+ margin: 0;
2615+ color: var(--color-text-muted);
2616+ font-size: var(--text-xs);
2617+ white-space: pre-wrap;
2618+ word-break: break-word;
2619+}
2620+.queue-item-actions {
2621+ display: flex;
2622+ gap: var(--space-2);
2623+ flex-shrink: 0;
2624+}
25772625 .ssh-key-info {
25782626 flex: 1;
25792627 display: flex;
Msrc/views/Settings.tsx
@@ -1,4 +1,4 @@
1-import { formatDate } from "../lib/formatDate.ts";
1+import { formatDate, formatDateTime } from "../lib/formatDate.ts";
22 import type { SessionUser } from "../middleware/session.ts";
33 import { Avatar } from "./Avatar.tsx";
44 import { Layout } from "./layout.tsx";
@@ -16,6 +16,12 @@ interface SettingsProps {
1616 theme: string;
1717 success: string | null;
1818 error: string | null;
19+ pendingUsers: {
20+ id: number;
21+ username: string;
22+ register_application: string | null;
23+ created_at: string;
24+ }[];
1925 }
2026
2127 const successMessages: Record<string, string> = {
@@ -25,6 +31,10 @@ const successMessages: Record<string, string> = {
2531 theme: "Theme preference saved.",
2632 user_created: "Account created.",
2733 user_deleted: "Account deleted.",
34+ user_approved: "Account approved.",
35+ user_denied: "Account denied.",
36+ all_approved: "All pending accounts approved.",
37+ all_denied: "All pending accounts denied.",
2838 ssh_key_added: "SSH key added.",
2939 ssh_key_deleted: "SSH key removed.",
3040 };
@@ -37,6 +47,7 @@ export function Settings({
3747 theme,
3848 success,
3949 error,
50+ pendingUsers,
4051 }: SettingsProps) {
4152 const successMsg = success ? (successMessages[success] ?? null) : null;
4253
@@ -355,6 +366,100 @@ export function Settings({
355366 </form>
356367 </div>
357368
369+ {/* Admin: Registration Queue */}
370+ {user.isAdmin && pendingUsers !== undefined && (
371+ <div class="form-card">
372+ <h2 class="section-title">Registration queue</h2>
373+ {pendingUsers.length === 0 ? (
374+ <p style="font-size: var(--text-sm); color: var(--color-text-muted); margin: 0">
375+ No pending registrations.
376+ </p>
377+ ) : (
378+ <div>
379+ <div class="queue-bulk-actions">
380+ <form
381+ method="POST"
382+ action="/admin/users/approve-all"
383+ >
384+ <button
385+ class="btn btn-sm btn-primary"
386+ type="submit"
387+ >
388+ Accept all
389+ </button>
390+ </form>
391+ <form
392+ method="POST"
393+ action="/admin/users/deny-all"
394+ >
395+ <button
396+ class="btn btn-sm btn-danger"
397+ type="submit"
398+ >
399+ Deny all
400+ </button>
401+ </form>
402+ </div>
403+ <ul class="queue-list">
404+ {pendingUsers.map((u) => (
405+ <li class="queue-item">
406+ <div class="queue-item-meta">
407+ <div class="queue-item-header">
408+ <strong>{u.username}</strong>
409+ <span class="queue-item-date">
410+ {formatDateTime(
411+ u.created_at,
412+ )}
413+ </span>
414+ </div>
415+ {u.register_application && (
416+ <p class="queue-item-answer">
417+ {u.register_application}
418+ </p>
419+ )}
420+ </div>
421+ <div class="queue-item-actions">
422+ <form
423+ method="POST"
424+ action="/admin/users/approve"
425+ >
426+ <input
427+ type="hidden"
428+ name="id"
429+ value={String(u.id)}
430+ />
431+ <button
432+ class="btn btn-sm btn-primary"
433+ type="submit"
434+ >
435+ Accept
436+ </button>
437+ </form>
438+ <form
439+ method="POST"
440+ action="/admin/users/deny"
441+ >
442+ <input
443+ type="hidden"
444+ name="id"
445+ value={String(u.id)}
446+ />
447+ <button
448+ class="btn btn-sm btn-danger"
449+ type="submit"
450+ >
451+ Deny
452+ </button>
453+ </form>
454+ </div>
455+ </li>
456+ ))}
457+ </ul>
458+ </div>
459+ )}
460+ </div>
461+ )}
462+
358463 {/* Admin: User Management */}
359464 {user.isAdmin && (
360465 <div class="form-card">
Msrc/views/auth/Register.tsx
@@ -2,9 +2,28 @@ import { Layout } from "../layout.tsx";
22
33 interface RegisterProps {
44 error?: string;
5+ question?: string;
6+ pending?: boolean;
57 }
68
7-export function Register({ error }: RegisterProps) {
9+export function Register({ error, question, pending }: RegisterProps) {
10+ if (pending) {
11+ return (
12+ <Layout user={null} title="Register">
13+ <div class="auth-container">
14+ <h1 class="page-title">Create account</h1>
15+ <p class="form-success">
16+ Your account has been submitted for review. You will be
17+ able to log in once an admin approves it.
18+ </p>
19+ <p class="auth-footer">
20+ Already have an account? <a href="/login">Sign in</a>
21+ </p>
22+ </div>
23+ </Layout>
24+ );
25+ }
26+
827 return (
928 <Layout user={null} title="Register">
1029 <div class="auth-container">
@@ -23,6 +42,18 @@ export function Register({ error }: RegisterProps) {
2342 title="Letters, numbers, hyphens and underscores only"
2443 />
2544 </div>
45+ {question && (
46+ <div class="form-group">
47+ <label for="application">{question}</label>
48+ <textarea
49+ id="application"
50+ name="application"
51+ rows="4"
52+ required
53+ placeholder="Your answer..."
54+ ></textarea>
55+ </div>
56+ )}
2657 <div id="passkey-section" style="display:none">
2758 <button
2859 id="passkey-register-btn"
@@ -66,6 +97,7 @@ export function Register({ error }: RegisterProps) {
6697 <script type="module">{`
6798 import { startRegistration } from '/assets/simplewebauthn-browser.js';
6899 const usernameInput = document.getElementById('username');
100+ const applicationInput = document.getElementById('application');
69101 document.getElementById('passkey-section').style.display = 'block';
70102 document.getElementById('passkey-register-btn').addEventListener('click', async () => {
71103 const username = usernameInput.value.trim();
@@ -74,18 +106,32 @@ export function Register({ error }: RegisterProps) {
74106 alert('Username may only contain letters, numbers, hyphens, and underscores');
75107 return;
76108 }
109+ if (applicationInput && !applicationInput.value.trim()) {
110+ applicationInput.focus();
111+ return;
112+ }
77113 try {
78114 const createResp = await fetch('/auth/passkey/create-user', {
79115 method: 'POST',
80116 headers: { 'Content-Type': 'application/json' },
81- body: JSON.stringify({ username }),
117+ body: JSON.stringify({
118+ username,
119+ application: applicationInput ? applicationInput.value.trim() : undefined,
120+ }),
82121 });
83122 if (!createResp.ok) {
84123 const err = await createResp.json();
85124 alert(err.error ?? 'Failed to create account');
86125 return;
87126 }
88- await createResp.json();
127+ const data = await createResp.json();
128+ if (data.pending) {
129+ document.querySelector('.auth-container').innerHTML =
130+ '<h1 class="page-title">Create account</h1>' +
131+ '<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' +
132+ '<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>';
133+ return;
134+ }
89135 const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
90136 const opts = await optsResp.json();
91137 const result = await startRegistration({ optionsJSON: opts });