this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5
6 interface TrustedDevice {
7 id: string
8 userAgent: string | null
9 friendlyName: string | null
10 trustedAt: string | null
11 trustedUntil: string | null
12 lastSeenAt: string
13 }
14
15 const auth = getAuthState()
16 let devices = $state<TrustedDevice[]>([])
17 let loading = $state(true)
18 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
19 let editingDeviceId = $state<string | null>(null)
20 let editDeviceName = $state('')
21
22 $effect(() => {
23 if (!auth.loading && !auth.session) {
24 navigate('/login')
25 }
26 })
27
28 $effect(() => {
29 if (auth.session) {
30 loadDevices()
31 }
32 })
33
34 async function loadDevices() {
35 if (!auth.session) return
36 loading = true
37 try {
38 const result = await api.listTrustedDevices(auth.session.accessJwt)
39 devices = result.devices
40 } catch {
41 showMessage('error', 'Failed to load trusted devices')
42 } finally {
43 loading = false
44 }
45 }
46
47 function showMessage(type: 'success' | 'error', text: string) {
48 message = { type, text }
49 setTimeout(() => {
50 if (message?.text === text) message = null
51 }, 5000)
52 }
53
54 async function handleRevoke(deviceId: string) {
55 if (!auth.session) return
56 if (!confirm('Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.')) return
57 try {
58 await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
59 await loadDevices()
60 showMessage('success', 'Device trust revoked')
61 } catch (e) {
62 showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device')
63 }
64 }
65
66 function startEditDevice(device: TrustedDevice) {
67 editingDeviceId = device.id
68 editDeviceName = device.friendlyName || ''
69 }
70
71 function cancelEditDevice() {
72 editingDeviceId = null
73 editDeviceName = ''
74 }
75
76 async function handleSaveDeviceName() {
77 if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
78 try {
79 await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
80 await loadDevices()
81 editingDeviceId = null
82 editDeviceName = ''
83 showMessage('success', 'Device renamed')
84 } catch (e) {
85 showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device')
86 }
87 }
88
89 function formatDate(dateStr: string): string {
90 return new Date(dateStr).toLocaleDateString(undefined, {
91 year: 'numeric',
92 month: 'short',
93 day: 'numeric',
94 hour: '2-digit',
95 minute: '2-digit'
96 })
97 }
98
99 function parseUserAgent(ua: string | null): string {
100 if (!ua) return 'Unknown device'
101 if (ua.includes('Firefox')) return 'Firefox'
102 if (ua.includes('Chrome')) return 'Chrome'
103 if (ua.includes('Safari')) return 'Safari'
104 if (ua.includes('Edge')) return 'Edge'
105 return 'Browser'
106 }
107
108 function getDaysRemaining(trustedUntil: string | null): number {
109 if (!trustedUntil) return 0
110 const now = new Date()
111 const until = new Date(trustedUntil)
112 const diff = until.getTime() - now.getTime()
113 return Math.ceil(diff / (1000 * 60 * 60 * 24))
114 }
115</script>
116
117<div class="page">
118 <header>
119 <a href="#/security" class="back">← Security Settings</a>
120 <h1>Trusted Devices</h1>
121 </header>
122
123 {#if message}
124 <div class="message {message.type}">{message.text}</div>
125 {/if}
126
127 <div class="description">
128 <p>
129 Trusted devices can skip two-factor authentication when logging in.
130 Trust is granted for 30 days and automatically extends when you use the device.
131 </p>
132 </div>
133
134 {#if loading}
135 <div class="loading">Loading...</div>
136 {:else if devices.length === 0}
137 <div class="empty-state">
138 <p>No trusted devices yet.</p>
139 <p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</p>
140 </div>
141 {:else}
142 <div class="device-list">
143 {#each devices as device}
144 <div class="device-card">
145 <div class="device-header">
146 {#if editingDeviceId === device.id}
147 <input
148 type="text"
149 class="edit-name-input"
150 bind:value={editDeviceName}
151 placeholder="Device name"
152 />
153 <div class="edit-actions">
154 <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button>
155 <button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button>
156 </div>
157 {:else}
158 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
159 <button class="btn-icon" onclick={() => startEditDevice(device)} title="Rename">
160 ✎
161 </button>
162 {/if}
163 </div>
164
165 <div class="device-details">
166 {#if device.userAgent && !device.friendlyName}
167 <p class="detail"><span class="label">Browser:</span> {device.userAgent}</p>
168 {:else if device.userAgent}
169 <p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p>
170 {/if}
171 <p class="detail">
172 <span class="label">Last seen:</span> {formatDate(device.lastSeenAt)}
173 </p>
174 {#if device.trustedAt}
175 <p class="detail">
176 <span class="label">Trusted since:</span> {formatDate(device.trustedAt)}
177 </p>
178 {/if}
179 {#if device.trustedUntil}
180 {@const daysRemaining = getDaysRemaining(device.trustedUntil)}
181 <p class="detail trust-expiry" class:expiring-soon={daysRemaining <= 7}>
182 <span class="label">Trust expires:</span>
183 {#if daysRemaining <= 0}
184 Expired
185 {:else if daysRemaining === 1}
186 Tomorrow
187 {:else}
188 In {daysRemaining} days
189 {/if}
190 </p>
191 {/if}
192 </div>
193
194 <div class="device-actions">
195 <button class="btn-danger" onclick={() => handleRevoke(device.id)}>
196 Revoke Trust
197 </button>
198 </div>
199 </div>
200 {/each}
201 </div>
202 {/if}
203</div>
204
205<style>
206 .page {
207 max-width: 600px;
208 margin: 0 auto;
209 padding: 2rem 1rem;
210 }
211
212 header {
213 margin-bottom: 2rem;
214 }
215
216 .back {
217 display: inline-block;
218 margin-bottom: 1rem;
219 color: var(--accent);
220 text-decoration: none;
221 font-size: 0.875rem;
222 }
223
224 .back:hover {
225 text-decoration: underline;
226 }
227
228 h1 {
229 margin: 0;
230 font-size: 1.75rem;
231 }
232
233 .message {
234 padding: 0.75rem 1rem;
235 border-radius: 4px;
236 margin-bottom: 1rem;
237 }
238
239 .message.success {
240 background: var(--success-bg);
241 color: var(--success-text);
242 border: 1px solid var(--success-border);
243 }
244
245 .message.error {
246 background: var(--error-bg);
247 color: var(--error-text);
248 border: 1px solid var(--error-border);
249 }
250
251 .description {
252 background: var(--bg-card);
253 border: 1px solid var(--border-color);
254 border-radius: 8px;
255 padding: 1rem;
256 margin-bottom: 1.5rem;
257 }
258
259 .description p {
260 margin: 0;
261 color: var(--text-secondary);
262 font-size: 0.9rem;
263 }
264
265 .loading {
266 text-align: center;
267 padding: 2rem;
268 color: var(--text-secondary);
269 }
270
271 .empty-state {
272 text-align: center;
273 padding: 3rem 1rem;
274 background: var(--bg-card);
275 border: 1px solid var(--border-color);
276 border-radius: 8px;
277 }
278
279 .empty-state p {
280 margin: 0;
281 color: var(--text-secondary);
282 }
283
284 .empty-state .hint {
285 margin-top: 0.5rem;
286 font-size: 0.875rem;
287 color: var(--text-muted);
288 }
289
290 .device-list {
291 display: flex;
292 flex-direction: column;
293 gap: 1rem;
294 }
295
296 .device-card {
297 background: var(--bg-card);
298 border: 1px solid var(--border-color);
299 border-radius: 8px;
300 padding: 1rem;
301 }
302
303 .device-header {
304 display: flex;
305 align-items: center;
306 gap: 0.5rem;
307 margin-bottom: 0.75rem;
308 }
309
310 .device-header h3 {
311 margin: 0;
312 flex: 1;
313 font-size: 1rem;
314 }
315
316 .edit-name-input {
317 flex: 1;
318 padding: 0.5rem;
319 border: 1px solid var(--border-color);
320 border-radius: 4px;
321 background: var(--bg-input);
322 color: var(--text-primary);
323 font-size: 0.9rem;
324 }
325
326 .edit-actions {
327 display: flex;
328 gap: 0.5rem;
329 }
330
331 .btn-icon {
332 background: none;
333 border: none;
334 color: var(--text-secondary);
335 cursor: pointer;
336 padding: 0.25rem;
337 font-size: 1rem;
338 }
339
340 .btn-icon:hover {
341 color: var(--text-primary);
342 }
343
344 .device-details {
345 margin-bottom: 0.75rem;
346 }
347
348 .detail {
349 margin: 0.25rem 0;
350 font-size: 0.875rem;
351 color: var(--text-secondary);
352 }
353
354 .detail .label {
355 color: var(--text-muted);
356 }
357
358 .trust-expiry.expiring-soon {
359 color: var(--warning-text);
360 }
361
362 .device-actions {
363 display: flex;
364 justify-content: flex-end;
365 padding-top: 0.75rem;
366 border-top: 1px solid var(--border-color);
367 }
368
369 .btn-small {
370 padding: 0.375rem 0.75rem;
371 border-radius: 4px;
372 font-size: 0.8rem;
373 cursor: pointer;
374 }
375
376 .btn-primary {
377 background: var(--accent);
378 color: white;
379 border: none;
380 }
381
382 .btn-primary:hover {
383 background: var(--accent-hover);
384 }
385
386 .btn-secondary {
387 background: var(--bg-input);
388 border: 1px solid var(--border-color);
389 color: var(--text-secondary);
390 }
391
392 .btn-secondary:hover {
393 background: var(--bg-secondary);
394 }
395
396 .btn-danger {
397 background: transparent;
398 border: 1px solid var(--error-border);
399 color: var(--error-text);
400 padding: 0.5rem 1rem;
401 border-radius: 4px;
402 cursor: pointer;
403 font-size: 0.875rem;
404 }
405
406 .btn-danger:hover {
407 background: var(--error-bg);
408 }
409</style>