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