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