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