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 {formatDate(pk.created_at)}
287 </span>
288 <form
289 method="POST"
290 action="/settings/passkey/revoke"
291 >
292 <input
293 type="hidden"
294 name="id"
295 value={String(pk.id)}
296 />
297 <button
298 class="btn btn-danger btn-sm"
299 type="submit"
300 disabled={
301 !hasPassword &&
302 passkeys.length <= 1
303 ? true
304 : undefined
305 }
306 title={
307 !hasPassword &&
308 passkeys.length <= 1
309 ? "Register another auth method first"
310 : undefined
311 }
312 >
313 Revoke
314 </button>
315 </form>
316 </div>
317 ))}
318 </div>
319 )}
320 <div id="passkey-section" style="display:none">
321 <button
322 id="add-passkey-btn"
323 class="btn btn-secondary"
324 type="button"
325 >
326 Add passkey
327 </button>
328 <p id="passkey-status" class="text-muted"></p>
329 </div>
330 <noscript>
331 <p class="text-muted">
332 Enable JavaScript to register or add passkeys.
333 </p>
334 </noscript>
335 </div>
336
337 {/* SSH Keys */}
338 <div class="form-card">
339 <h2 class="section-title">SSH Keys</h2>
340 {sshKeys.length > 0 && (
341 <div class="passkey-list">
342 {sshKeys.map((key) => (
343 <div class="passkey-item">
344 <div class="ssh-key-info">
345 <span class="ssh-key-name">
346 {key.name}
347 </span>
348 <span class="passkey-date ssh-key-fingerprint">
349 {key.fingerprint}
350 </span>
351 <span class="passkey-date">
352 Added {formatDate(key.created_at)}
353 </span>
354 </div>
355 <form
356 method="POST"
357 action="/settings/ssh-keys/delete"
358 >
359 <input
360 type="hidden"
361 name="id"
362 value={String(key.id)}
363 />
364 <button
365 class="btn btn-danger btn-sm"
366 type="submit"
367 >
368 Remove
369 </button>
370 </form>
371 </div>
372 ))}
373 </div>
374 )}
375 <form
376 method="POST"
377 action="/settings/ssh-keys"
378 class="settings-form"
379 style="margin-top: var(--space-4)"
380 >
381 <div class="form-group">
382 <label class="form-label" for="ssh_key_name">
383 Name
384 </label>
385 <input
386 class="form-input"
387 type="text"
388 id="ssh_key_name"
389 name="name"
390 placeholder="e.g. My laptop"
391 autocomplete="off"
392 />
393 </div>
394 <div class="form-group">
395 <label class="form-label" for="ssh_public_key">
396 Public key
397 </label>
398 <textarea
399 class="form-input form-textarea"
400 id="ssh_public_key"
401 name="public_key"
402 placeholder="ssh-ed25519 AAAA..."
403 rows="3"
404 required
405 ></textarea>
406 </div>
407 <div class="form-actions">
408 <button class="btn btn-primary" type="submit">
409 Add SSH key
410 </button>
411 </div>
412 </form>
413 </div>
414
415 {/* Admin: User Management */}
416 {user.isAdmin && (
417 <div class="form-card">
418 <h2 class="section-title">User Management</h2>
419 <h3>Create account</h3>
420 <form
421 method="POST"
422 action="/admin/users"
423 class="settings-form"
424 style="margin-top: var(--space-4)"
425 >
426 <div class="form-group">
427 <label class="form-label" for="new_username">
428 Username
429 </label>
430 <input
431 class="form-input"
432 type="text"
433 id="new_username"
434 name="username"
435 autocomplete="off"
436 />
437 </div>
438 <div class="form-group">
439 <label
440 class="form-label"
441 for="new_user_password"
442 >
443 Password
444 </label>
445 <input
446 class="form-input"
447 type="password"
448 id="new_user_password"
449 name="password"
450 autocomplete="new-password"
451 />
452 </div>
453 <div class="form-actions">
454 <button class="btn btn-primary" type="submit">
455 Create account
456 </button>
457 </div>
458 </form>
459 <h3 style="margin-top: var(--space-6)">
460 Delete account
461 </h3>
462 <form
463 method="POST"
464 action="/admin/users/delete"
465 class="settings-form"
466 style="margin-top: var(--space-4)"
467 >
468 <div class="form-group">
469 <label class="form-label" for="del_username">
470 Username
471 </label>
472 <input
473 class="form-input"
474 type="text"
475 id="del_username"
476 name="username"
477 autocomplete="off"
478 />
479 </div>
480 <div class="form-actions">
481 <button class="btn btn-danger" type="submit">
482 Delete account
483 </button>
484 </div>
485 </form>
486 </div>
487 )}
488 </div>
489
490 <script type="module">{`
491 import { startRegistration } from '/assets/simplewebauthn-browser.js';
492 document.getElementById('passkey-section').style.display = 'block';
493 document.getElementById('add-passkey-btn').addEventListener('click', async () => {
494 const status = document.getElementById('passkey-status');
495 try {
496 status.textContent = 'Starting...';
497 const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
498 const opts = await optsResp.json();
499 const result = await startRegistration({ optionsJSON: opts });
500 const verResp = await fetch('/auth/passkey/register/verify', {
501 method: 'POST',
502 headers: { 'Content-Type': 'application/json' },
503 body: JSON.stringify(result),
504 });
505 if (verResp.ok) {
506 window.location.reload();
507 } else {
508 const err = await verResp.json();
509 status.textContent = 'Error: ' + (err.error ?? 'Registration failed');
510 }
511 } catch (e) {
512 status.textContent = 'Error: ' + e.message;
513 }
514 });
515 `}</script>
516 </Layout>
517 );
518}
519