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