Settings.tsx
Raw
1import config from "../config.ts";
2import { formatDate, formatDateTime } from "../lib/formatDate.ts";
3import type { SessionUser } from "../middleware/session.ts";
4import { Avatar } from "./Avatar.tsx";
5import { Layout } from "./layout.tsx";
6
7interface SettingsProps {
8 user: SessionUser;
9 hasPassword: boolean;
10 passkeys: { id: number; created_at: string }[];
11 sshKeys: {
12 id: number;
13 name: string;
14 fingerprint: string;
15 created_at: string;
16 }[];
17 theme: string;
18 success: string | null;
19 error: string | null;
20 pendingUsers: {
21 id: number;
22 username: string;
23 register_application: string | null;
24 created_at: string;
25 }[];
26}
27
28const successMessages: Record<string, string> = {
29 password: "Password updated.",
30 password_removed: "Password removed.",
31 passkey_revoked: "Passkey revoked.",
32 theme: "Theme preference saved.",
33 user_created: "Account created.",
34 user_deleted: "Account deleted.",
35 user_approved: "Account approved.",
36 user_denied: "Account denied.",
37 all_approved: "All pending accounts approved.",
38 all_denied: "All pending accounts denied.",
39 ssh_key_added: "SSH key added.",
40 ssh_key_deleted: "SSH key removed.",
41};
42
43export function Settings({
44 user,
45 hasPassword,
46 passkeys,
47 sshKeys,
48 theme,
49 success,
50 error,
51 pendingUsers,
52}: SettingsProps) {
53 const successMsg = success ? (successMessages[success] ?? null) : null;
54
55 return (
56 <Layout user={user} title="Settings">
57 <div class="container container-narrow">
58 <h1 class="page-title">Settings</h1>
59
60 {successMsg && <p class="form-success">{successMsg}</p>}
61 {error && <p class="form-error">{error}</p>}
62
63 {/* Avatar */}
64 <div class="form-card">
65 <h2 class="section-title">Avatar</h2>
66 <div class="avatar-settings">
67 <Avatar
68 userId={user.id}
69 version={user.avatar_version}
70 size={80}
71 />
72 <div class="avatar-actions">
73 <form
74 method="POST"
75 action="/settings/avatar"
76 enctype="multipart/form-data"
77 >
78 <div class="form-group">
79 <input
80 type="file"
81 name="avatar"
82 accept="image/*"
83 class="form-input"
84 required
85 />
86 </div>
87 <div class="form-actions">
88 <button
89 type="submit"
90 class="btn btn-sm btn-primary"
91 >
92 Upload avatar
93 </button>
94 </div>
95 </form>
96 <form
97 method="POST"
98 action="/settings/avatar/delete"
99 class="inline-form"
100 >
101 <button type="submit" class="btn btn-sm">
102 Remove avatar
103 </button>
104 </form>
105 </div>
106 </div>
107 </div>
108
109 {/* Appearance */}
110 <div class="form-card">
111 <h2 class="section-title">Appearance</h2>
112 <form method="POST" action="/settings/theme">
113 <div class="theme-options">
114 <label class="theme-option">
115 <input
116 type="radio"
117 name="theme"
118 value="auto"
119 checked={
120 theme === "auto" ? true : undefined
121 }
122 />
123 Auto
124 </label>
125 <label class="theme-option">
126 <input
127 type="radio"
128 name="theme"
129 value="light"
130 checked={
131 theme === "light" ? true : undefined
132 }
133 />
134 Light
135 </label>
136 <label class="theme-option">
137 <input
138 type="radio"
139 name="theme"
140 value="dark"
141 checked={
142 theme === "dark" ? true : undefined
143 }
144 />
145 Dark
146 </label>
147 </div>
148 <div class="form-actions">
149 <button class="btn btn-primary" type="submit">
150 Save
151 </button>
152 </div>
153 </form>
154 </div>
155
156 {/* Password */}
157 <div class="form-card">
158 <h2 class="section-title">Password</h2>
159 <p class="text-muted">
160 {hasPassword ? "Password is set." : "No password set."}
161 </p>
162 <form
163 method="POST"
164 action="/settings/password"
165 class="settings-form"
166 >
167 {hasPassword && (
168 <div class="form-group">
169 <label
170 class="form-label"
171 for="current_password"
172 >
173 Current password
174 </label>
175 <input
176 class="form-input"
177 type="password"
178 id="current_password"
179 name="current_password"
180 autocomplete="current-password"
181 maxlength={config.MAX_PASSWORD_BYTES}
182 />
183 </div>
184 )}
185 <div class="form-group">
186 <label class="form-label" for="new_password">
187 {hasPassword ? "New password" : "Password"}
188 </label>
189 <input
190 class="form-input"
191 type="password"
192 id="new_password"
193 name="new_password"
194 autocomplete="new-password"
195 maxlength={config.MAX_PASSWORD_BYTES}
196 />
197 </div>
198 <div class="form-group">
199 <label class="form-label" for="confirm_password">
200 Confirm password
201 </label>
202 <input
203 class="form-input"
204 type="password"
205 id="confirm_password"
206 name="confirm_password"
207 autocomplete="new-password"
208 maxlength={config.MAX_PASSWORD_BYTES}
209 />
210 </div>
211 <div class="form-actions">
212 <button class="btn btn-primary" type="submit">
213 {hasPassword
214 ? "Change password"
215 : "Set password"}
216 </button>
217 </div>
218 </form>
219 {hasPassword && passkeys.length > 0 && (
220 <form
221 method="POST"
222 action="/settings/password/remove"
223 style="margin-top: var(--space-4)"
224 >
225 <button
226 class="btn btn-danger btn-sm"
227 type="submit"
228 data-confirm="Remove password? You will need a passkey to sign in."
229 >
230 Remove password
231 </button>
232 </form>
233 )}
234 </div>
235
236 {/* Passkeys */}
237 <div class="form-card">
238 <h2 class="section-title">Passkeys</h2>
239 {passkeys.length > 0 && (
240 <div class="passkey-list">
241 {passkeys.map((pk) => (
242 <div class="passkey-item">
243 <span class="passkey-date">
244 Added {formatDate(pk.created_at)}
245 </span>
246 <form
247 method="POST"
248 action="/settings/passkey/revoke"
249 >
250 <input
251 type="hidden"
252 name="id"
253 value={String(pk.id)}
254 />
255 <button
256 class="btn btn-danger btn-sm"
257 type="submit"
258 disabled={
259 !hasPassword &&
260 passkeys.length <= 1
261 ? true
262 : undefined
263 }
264 title={
265 !hasPassword &&
266 passkeys.length <= 1
267 ? "Register another auth method first"
268 : undefined
269 }
270 >
271 Revoke
272 </button>
273 </form>
274 </div>
275 ))}
276 </div>
277 )}
278 <div id="passkey-section" style="display:none">
279 <button
280 id="add-passkey-btn"
281 class="btn btn-secondary"
282 type="button"
283 >
284 Add passkey
285 </button>
286 <p id="passkey-status" class="text-muted"></p>
287 </div>
288 <noscript>
289 <p class="text-muted">
290 Enable JavaScript to register or add passkeys.
291 </p>
292 </noscript>
293 </div>
294
295 {/* SSH Keys */}
296 <div class="form-card">
297 <h2 class="section-title">SSH Keys</h2>
298 {sshKeys.length > 0 && (
299 <div class="passkey-list">
300 {sshKeys.map((key) => (
301 <div class="passkey-item">
302 <div class="ssh-key-info">
303 <span class="ssh-key-name">
304 {key.name}
305 </span>
306 <span class="passkey-date ssh-key-fingerprint">
307 {key.fingerprint}
308 </span>
309 <span class="passkey-date">
310 Added {formatDate(key.created_at)}
311 </span>
312 </div>
313 <form
314 method="POST"
315 action="/settings/ssh-keys/delete"
316 >
317 <input
318 type="hidden"
319 name="id"
320 value={String(key.id)}
321 />
322 <button
323 class="btn btn-danger btn-sm"
324 type="submit"
325 >
326 Remove
327 </button>
328 </form>
329 </div>
330 ))}
331 </div>
332 )}
333 <form
334 method="POST"
335 action="/settings/ssh-keys"
336 class="settings-form"
337 style="margin-top: var(--space-4)"
338 >
339 <div class="form-group">
340 <label class="form-label" for="ssh_key_name">
341 Name
342 </label>
343 <input
344 class="form-input"
345 type="text"
346 id="ssh_key_name"
347 name="name"
348 placeholder="e.g. My laptop"
349 autocomplete="off"
350 />
351 </div>
352 <div class="form-group">
353 <label class="form-label" for="ssh_public_key">
354 Public key
355 </label>
356 <textarea
357 class="form-input form-textarea"
358 id="ssh_public_key"
359 name="public_key"
360 placeholder="ssh-ed25519 AAAA..."
361 rows="3"
362 required
363 ></textarea>
364 </div>
365 <div class="form-actions">
366 <button class="btn btn-primary" type="submit">
367 Add SSH key
368 </button>
369 </div>
370 </form>
371 </div>
372
373 {/* Admin: Registration Queue */}
374 {user.isAdmin && pendingUsers !== undefined && (
375 <div class="form-card">
376 <h2 class="section-title">Registration queue</h2>
377 {pendingUsers.length === 0 ? (
378 <p style="font-size: var(--text-sm); color: var(--color-text-muted); margin: 0">
379 No pending registrations.
380 </p>
381 ) : (
382 <div>
383 <div class="queue-bulk-actions">
384 <form
385 method="POST"
386 action="/admin/users/approve-all"
387 >
388 <button
389 class="btn btn-sm btn-primary"
390 type="submit"
391 >
392 Accept all
393 </button>
394 </form>
395 <form
396 method="POST"
397 action="/admin/users/deny-all"
398 >
399 <button
400 class="btn btn-sm btn-danger"
401 type="submit"
402 >
403 Deny all
404 </button>
405 </form>
406 </div>
407 <ul class="queue-list">
408 {pendingUsers.map((u) => (
409 <li class="queue-item">
410 <div class="queue-item-meta">
411 <div class="queue-item-header">
412 <strong>
413 {u.username}
414 </strong>
415 <span class="queue-item-date">
416 {formatDateTime(
417 u.created_at,
418 )}
419 </span>
420 </div>
421 {u.register_application && (
422 <p class="queue-item-answer">
423 {u.register_application}
424 </p>
425 )}
426 </div>
427 <div class="queue-item-actions">
428 <form
429 method="POST"
430 action="/admin/users/approve"
431 >
432 <input
433 type="hidden"
434 name="id"
435 value={String(u.id)}
436 />
437 <button
438 class="btn btn-sm btn-primary"
439 type="submit"
440 >
441 Accept
442 </button>
443 </form>
444 <form
445 method="POST"
446 action="/admin/users/deny"
447 >
448 <input
449 type="hidden"
450 name="id"
451 value={String(u.id)}
452 />
453 <button
454 class="btn btn-sm btn-danger"
455 type="submit"
456 >
457 Deny
458 </button>
459 </form>
460 </div>
461 </li>
462 ))}
463 </ul>
464 </div>
465 )}
466 </div>
467 )}
468
469 {/* Admin: User Management */}
470 {user.isAdmin && (
471 <div class="form-card">
472 <h2 class="section-title">User Management</h2>
473 <h3>Create account</h3>
474 <form
475 method="POST"
476 action="/admin/users"
477 class="settings-form"
478 style="margin-top: var(--space-4)"
479 >
480 <div class="form-group">
481 <label class="form-label" for="new_username">
482 Username
483 </label>
484 <input
485 class="form-input"
486 type="text"
487 id="new_username"
488 name="username"
489 autocomplete="off"
490 />
491 </div>
492 <div class="form-group">
493 <label
494 class="form-label"
495 for="new_user_password"
496 >
497 Password
498 </label>
499 <input
500 class="form-input"
501 type="password"
502 id="new_user_password"
503 name="password"
504 autocomplete="new-password"
505 />
506 </div>
507 <div class="form-actions">
508 <button class="btn btn-primary" type="submit">
509 Create account
510 </button>
511 </div>
512 </form>
513 <h3 style="margin-top: var(--space-6)">
514 Delete account
515 </h3>
516 <form
517 method="POST"
518 action="/admin/users/delete"
519 class="settings-form"
520 style="margin-top: var(--space-4)"
521 >
522 <div class="form-group">
523 <label class="form-label" for="del_username">
524 Username
525 </label>
526 <input
527 class="form-input"
528 type="text"
529 id="del_username"
530 name="username"
531 autocomplete="off"
532 />
533 </div>
534 <div class="form-actions">
535 <button class="btn btn-danger" type="submit">
536 Delete account
537 </button>
538 </div>
539 </form>
540 </div>
541 )}
542 </div>
543
544 <script
545 type="module"
546 src="/assets/passkey-settings.js"
547 ></script>
548 </Layout>
549 );
550}
551