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