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 class="passkey-date ssh-key-fingerprint" safe>
315 {key.fingerprint}
316 </span>
317 <span class="passkey-date" safe>
318 Added {formatDate(key.created_at)}
319 </span>
320 </div>
321 <form
322 method="POST"
323 action="/settings/ssh-keys/delete"
324 >
325 <input
326 type="hidden"
327 name="id"
328 value={String(key.id)}
329 />
330 <button
331 class="btn btn-danger btn-sm"
332 type="submit"
333 >
334 Remove
335 </button>
336 </form>
337 </div>
338 ))}
339 </div>
340 )}
341 <form
342 method="POST"
343 action="/settings/ssh-keys"
344 class="settings-form"
345 style="margin-top: var(--space-4)"
346 >
347 <div class="form-group">
348 <label class="form-label" for="ssh_key_name">
349 Name
350 </label>
351 <input
352 class="form-input"
353 type="text"
354 id="ssh_key_name"
355 name="name"
356 placeholder="e.g. My laptop"
357 autocomplete="off"
358 />
359 </div>
360 <div class="form-group">
361 <label class="form-label" for="ssh_public_key">
362 Public key
363 </label>
364 <textarea
365 class="form-input form-textarea"
366 id="ssh_public_key"
367 name="public_key"
368 placeholder="ssh-ed25519 AAAA..."
369 rows="3"
370 required
371 ></textarea>
372 </div>
373 <div class="form-actions">
374 <button class="btn btn-primary" type="submit">
375 Add SSH key
376 </button>
377 </div>
378 </form>
379 </div>
380
381 {/* Admin: Registration Queue */}
382 {user.isAdmin && pendingUsers !== undefined && (
383 <div class="form-card">
384 <h2 class="section-title">Registration queue</h2>
385 {pendingUsers.length === 0 ? (
386 <p style="font-size: var(--text-sm); color: var(--color-text-muted); margin: 0">
387 No pending registrations.
388 </p>
389 ) : (
390 <div>
391 <div class="queue-bulk-actions">
392 <form
393 method="POST"
394 action="/admin/users/approve-all"
395 >
396 <button
397 class="btn btn-sm btn-primary"
398 type="submit"
399 >
400 Accept all
401 </button>
402 </form>
403 <form
404 method="POST"
405 action="/admin/users/deny-all"
406 >
407 <button
408 class="btn btn-sm btn-danger"
409 type="submit"
410 >
411 Deny all
412 </button>
413 </form>
414 </div>
415 <ul class="queue-list">
416 {pendingUsers.map((u) => (
417 <li class="queue-item">
418 <div class="queue-item-meta">
419 <div class="queue-item-header">
420 <strong safe>
421 {u.username}
422 </strong>
423 <span class="queue-item-date" safe>
424 {formatDateTime(
425 u.created_at,
426 )}
427 </span>
428 </div>
429 {!!u.register_application && (
430 <p class="queue-item-answer" safe>
431 {u.register_application}
432 </p>
433 )}
434 </div>
435 <div class="queue-item-actions">
436 <form
437 method="POST"
438 action="/admin/users/approve"
439 >
440 <input
441 type="hidden"
442 name="id"
443 value={String(u.id)}
444 />
445 <button
446 class="btn btn-sm btn-primary"
447 type="submit"
448 >
449 Accept
450 </button>
451 </form>
452 <form
453 method="POST"
454 action="/admin/users/deny"
455 >
456 <input
457 type="hidden"
458 name="id"
459 value={String(u.id)}
460 />
461 <button
462 class="btn btn-sm btn-danger"
463 type="submit"
464 >
465 Deny
466 </button>
467 </form>
468 </div>
469 </li>
470 ))}
471 </ul>
472 </div>
473 )}
474 </div>
475 )}
476
477 {/* Admin: User Management */}
478 {user.isAdmin && (
479 <div class="form-card">
480 <h2 class="section-title">User Management</h2>
481 <h3>Create account</h3>
482 <form
483 method="POST"
484 action="/admin/users"
485 class="settings-form"
486 style="margin-top: var(--space-4)"
487 >
488 <div class="form-group">
489 <label class="form-label" for="new_username">
490 Username
491 </label>
492 <input
493 class="form-input"
494 type="text"
495 id="new_username"
496 name="username"
497 autocomplete="off"
498 />
499 </div>
500 <div class="form-group">
501 <label
502 class="form-label"
503 for="new_user_password"
504 >
505 Password
506 </label>
507 <input
508 class="form-input"
509 type="password"
510 id="new_user_password"
511 name="password"
512 autocomplete="new-password"
513 />
514 </div>
515 <div class="form-actions">
516 <button class="btn btn-primary" type="submit">
517 Create account
518 </button>
519 </div>
520 </form>
521 <h3 style="margin-top: var(--space-6)">
522 Delete account
523 </h3>
524 <form
525 method="POST"
526 action="/admin/users/delete"
527 class="settings-form"
528 style="margin-top: var(--space-4)"
529 >
530 <div class="form-group">
531 <label class="form-label" for="del_username">
532 Username
533 </label>
534 <input
535 class="form-input"
536 type="text"
537 id="del_username"
538 name="username"
539 autocomplete="off"
540 />
541 </div>
542 <div class="form-actions">
543 <button class="btn btn-danger" type="submit">
544 Delete account
545 </button>
546 </div>
547 </form>
548 </div>
549 )}
550 </div>
551
552 <script type="module" src="/assets/passkey-settings.js"></script>
553 </Layout>
554 );
555}
556