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