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