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>{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
463 {/* Admin: User Management */}
464 {user.isAdmin && (
465 <div class="form-card">
466 <h2 class="section-title">User Management</h2>
467 <h3>Create account</h3>
468 <form
469 method="POST"
470 action="/admin/users"
471 class="settings-form"
472 style="margin-top: var(--space-4)"
473 >
474 <div class="form-group">
475 <label class="form-label" for="new_username">
476 Username
477 </label>
478 <input
479 class="form-input"
480 type="text"
481 id="new_username"
482 name="username"
483 autocomplete="off"
484 />
485 </div>
486 <div class="form-group">
487 <label
488 class="form-label"
489 for="new_user_password"
490 >
491 Password
492 </label>
493 <input
494 class="form-input"
495 type="password"
496 id="new_user_password"
497 name="password"
498 autocomplete="new-password"
499 />
500 </div>
501 <div class="form-actions">
502 <button class="btn btn-primary" type="submit">
503 Create account
504 </button>
505 </div>
506 </form>
507 <h3 style="margin-top: var(--space-6)">
508 Delete account
509 </h3>
510 <form
511 method="POST"
512 action="/admin/users/delete"
513 class="settings-form"
514 style="margin-top: var(--space-4)"
515 >
516 <div class="form-group">
517 <label class="form-label" for="del_username">
518 Username
519 </label>
520 <input
521 class="form-input"
522 type="text"
523 id="del_username"
524 name="username"
525 autocomplete="off"
526 />
527 </div>
528 <div class="form-actions">
529 <button class="btn btn-danger" type="submit">
530 Delete account
531 </button>
532 </div>
533 </form>
534 </div>
535 )}
536 </div>
537
538 <script type="module">{`
539 import { startRegistration } from '/assets/simplewebauthn-browser.js';
540 document.getElementById('passkey-section').style.display = 'block';
541 document.getElementById('add-passkey-btn').addEventListener('click', async () => {
542 const status = document.getElementById('passkey-status');
543 try {
544 status.textContent = 'Starting...';
545 const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
546 const opts = await optsResp.json();
547 const result = await startRegistration({ optionsJSON: opts });
548 const verResp = await fetch('/auth/passkey/register/verify', {
549 method: 'POST',
550 headers: { 'Content-Type': 'application/json' },
551 body: JSON.stringify(result),
552 });
553 if (verResp.ok) {
554 window.location.reload();
555 } else {
556 const err = await verResp.json();
557 status.textContent = 'Error: ' + (err.error ?? 'Registration failed');
558 }
559 } catch (e) {
560 status.textContent = 'Error: ' + e.message;
561 }
562 });
563 `}</script>
564 </Layout>
565 );
566}
567