this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 import { _ } from '../lib/i18n'
6 import { formatDateTime } from '../lib/date'
7 import type { Session } from '../lib/types/api'
8 import { toast } from '../lib/toast.svelte'
9
10 interface TrustedDevice {
11 id: string
12 userAgent: string | null
13 friendlyName: string | null
14 trustedAt: string | null
15 trustedUntil: string | null
16 lastSeenAt: string
17 }
18
19 const auth = $derived(getAuthState())
20
21 function getSession(): Session | null {
22 return auth.kind === 'authenticated' ? auth.session : null
23 }
24
25 function isLoading(): boolean {
26 return auth.kind === 'loading'
27 }
28
29 const session = $derived(getSession())
30 const authLoading = $derived(isLoading())
31 let devices = $state<TrustedDevice[]>([])
32 let loading = $state(true)
33 let editingDeviceId = $state<string | null>(null)
34 let editDeviceName = $state('')
35
36 $effect(() => {
37 if (!authLoading && !session) {
38 navigate(routes.login)
39 }
40 })
41
42 $effect(() => {
43 if (session) {
44 loadDevices()
45 }
46 })
47
48 async function loadDevices() {
49 if (!session) return
50 loading = true
51 try {
52 const result = await api.listTrustedDevices(session.accessJwt)
53 devices = result.devices
54 } catch {
55 toast.error($_('trustedDevices.failedToLoad'))
56 } finally {
57 loading = false
58 }
59 }
60
61 async function handleRevoke(deviceId: string) {
62 if (!session) return
63 if (!confirm($_('trustedDevices.revokeConfirm'))) return
64 try {
65 await api.revokeTrustedDevice(session.accessJwt, deviceId)
66 await loadDevices()
67 toast.success($_('trustedDevices.deviceRevoked'))
68 } catch (e) {
69 toast.error(e instanceof ApiError ? e.message : $_('common.error'))
70 }
71 }
72
73 function startEditDevice(device: TrustedDevice) {
74 editingDeviceId = device.id
75 editDeviceName = device.friendlyName || ''
76 }
77
78 function cancelEditDevice() {
79 editingDeviceId = null
80 editDeviceName = ''
81 }
82
83 async function handleSaveDeviceName() {
84 if (!session || !editingDeviceId || !editDeviceName.trim()) return
85 try {
86 await api.updateTrustedDevice(session.accessJwt, editingDeviceId, editDeviceName.trim())
87 await loadDevices()
88 editingDeviceId = null
89 editDeviceName = ''
90 toast.success($_('trustedDevices.deviceRenamed'))
91 } catch (e) {
92 toast.error(e instanceof ApiError ? e.message : $_('common.error'))
93 }
94 }
95
96 function formatDate(dateStr: string): string {
97 return formatDateTime(dateStr)
98 }
99
100 function parseUserAgent(ua: string | null): string {
101 if (!ua) return $_('trustedDevices.unknownDevice')
102 if (ua.includes('Firefox')) return 'Firefox'
103 if (ua.includes('Chrome')) return 'Chrome'
104 if (ua.includes('Safari')) return 'Safari'
105 if (ua.includes('Edge')) return 'Edge'
106 return 'Browser'
107 }
108
109 function getDaysRemaining(trustedUntil: string | null): number {
110 if (!trustedUntil) return 0
111 const now = new Date()
112 const until = new Date(trustedUntil)
113 const diff = until.getTime() - now.getTime()
114 return Math.ceil(diff / (1000 * 60 * 60 * 24))
115 }
116</script>
117
118<div class="page">
119 <header>
120 <a href={getFullUrl(routes.security)} class="back">{$_('trustedDevices.backToSecurity')}</a>
121 <h1>{$_('trustedDevices.title')}</h1>
122 </header>
123
124 <div class="description">
125 <p>
126 {$_('trustedDevices.description')}
127 </p>
128 </div>
129
130 {#if loading}
131 <div class="skeleton-list">
132 {#each Array(2) as _}
133 <div class="skeleton-card"></div>
134 {/each}
135 </div>
136 {:else if devices.length === 0}
137 <div class="empty-state">
138 <p>{$_('trustedDevices.noDevices')}</p>
139 <p class="hint">{$_('trustedDevices.noDevicesHint')}</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={$_('trustedDevices.deviceNamePlaceholder')}
152 />
153 <div class="edit-actions">
154 <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
155 <button class="btn-small btn-secondary" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
156 </div>
157 {:else}
158 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
159 <button class="btn-icon" onclick={() => startEditDevice(device)} title={$_('security.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">{$_('trustedDevices.browser')}</span> {device.userAgent}</p>
168 {:else if device.userAgent}
169 <p class="detail"><span class="label">{$_('trustedDevices.browser')}</span> {parseUserAgent(device.userAgent)}</p>
170 {/if}
171 <p class="detail">
172 <span class="label">{$_('trustedDevices.lastSeen')}</span> {formatDate(device.lastSeenAt)}
173 </p>
174 {#if device.trustedAt}
175 <p class="detail">
176 <span class="label">{$_('trustedDevices.trustedSince')}</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">{$_('trustedDevices.trustExpires')}</span>
183 {#if daysRemaining <= 0}
184 {$_('trustedDevices.expired')}
185 {:else if daysRemaining === 1}
186 {$_('trustedDevices.tomorrow')}
187 {:else}
188 {$_('trustedDevices.inDays', { values: { days: daysRemaining } })}
189 {/if}
190 </p>
191 {/if}
192 </div>
193
194 <div class="device-actions">
195 <button class="btn-danger" onclick={() => handleRevoke(device.id)}>
196 {$_('trustedDevices.revoke')}
197 </button>
198 </div>
199 </div>
200 {/each}
201 </div>
202 {/if}
203</div>
204
205<style>
206 .page {
207 max-width: var(--width-lg);
208 margin: 0 auto;
209 padding: var(--space-7);
210 }
211
212 header {
213 margin-bottom: var(--space-7);
214 }
215
216 .back {
217 display: inline-block;
218 margin-bottom: var(--space-4);
219 color: var(--accent);
220 text-decoration: none;
221 font-size: var(--text-sm);
222 }
223
224 .back:hover {
225 text-decoration: underline;
226 }
227
228 h1 {
229 margin: 0;
230 font-size: var(--text-2xl);
231 }
232
233 .description {
234 background: var(--bg-card);
235 border: 1px solid var(--border-color);
236 border-radius: var(--radius-xl);
237 padding: var(--space-4);
238 margin-bottom: var(--space-6);
239 }
240
241 .description p {
242 margin: 0;
243 color: var(--text-secondary);
244 font-size: var(--text-sm);
245 }
246
247 .loading {
248 text-align: center;
249 padding: var(--space-7);
250 color: var(--text-secondary);
251 }
252
253 .empty-state {
254 text-align: center;
255 padding: var(--space-8) var(--space-4);
256 background: var(--bg-card);
257 border: 1px solid var(--border-color);
258 border-radius: var(--radius-xl);
259 }
260
261 .empty-state p {
262 margin: 0;
263 color: var(--text-secondary);
264 }
265
266 .empty-state .hint {
267 margin-top: var(--space-2);
268 font-size: var(--text-sm);
269 color: var(--text-muted);
270 }
271
272 .device-list {
273 display: flex;
274 flex-direction: column;
275 gap: var(--space-4);
276 }
277
278 .device-card {
279 background: var(--bg-card);
280 border: 1px solid var(--border-color);
281 border-radius: var(--radius-xl);
282 padding: var(--space-4);
283 }
284
285 .device-header {
286 display: flex;
287 align-items: center;
288 gap: var(--space-2);
289 margin-bottom: var(--space-3);
290 }
291
292 .device-header h3 {
293 margin: 0;
294 flex: 1;
295 font-size: var(--text-base);
296 }
297
298 .edit-name-input {
299 flex: 1;
300 padding: var(--space-2);
301 font-size: var(--text-sm);
302 }
303
304 .edit-actions {
305 display: flex;
306 gap: var(--space-2);
307 }
308
309 .btn-icon {
310 background: none;
311 border: none;
312 color: var(--text-secondary);
313 cursor: pointer;
314 padding: var(--space-1);
315 font-size: var(--text-base);
316 }
317
318 .btn-icon:hover {
319 color: var(--text-primary);
320 }
321
322 .device-details {
323 margin-bottom: var(--space-3);
324 }
325
326 .detail {
327 margin: var(--space-1) 0;
328 font-size: var(--text-sm);
329 color: var(--text-secondary);
330 }
331
332 .detail .label {
333 color: var(--text-muted);
334 }
335
336 .trust-expiry.expiring-soon {
337 color: var(--warning-text);
338 }
339
340 .device-actions {
341 display: flex;
342 justify-content: flex-end;
343 padding-top: var(--space-3);
344 border-top: 1px solid var(--border-color);
345 }
346
347 .btn-small {
348 padding: var(--space-2) var(--space-3);
349 border-radius: var(--radius-md);
350 font-size: var(--text-xs);
351 cursor: pointer;
352 }
353
354 .btn-primary {
355 background: var(--accent);
356 color: var(--text-inverse);
357 border: none;
358 }
359
360 .btn-primary:hover {
361 background: var(--accent-hover);
362 }
363
364 .btn-secondary {
365 background: var(--bg-input);
366 border: 1px solid var(--border-color);
367 color: var(--text-secondary);
368 }
369
370 .btn-secondary:hover {
371 background: var(--bg-secondary);
372 }
373
374 .btn-danger {
375 background: transparent;
376 border: 1px solid var(--error-border);
377 color: var(--error-text);
378 padding: var(--space-2) var(--space-4);
379 border-radius: var(--radius-md);
380 cursor: pointer;
381 font-size: var(--text-sm);
382 }
383
384 .btn-danger:hover {
385 background: var(--error-bg);
386 }
387
388 .skeleton-list {
389 display: flex;
390 flex-direction: column;
391 gap: var(--space-4);
392 }
393
394 .skeleton-card {
395 height: 100px;
396 background: var(--bg-secondary);
397 border: 1px solid var(--border-color);
398 border-radius: var(--radius-xl);
399 animation: skeleton-pulse 1.5s ease-in-out infinite;
400 }
401
402 @keyframes skeleton-pulse {
403 0%, 100% { opacity: 1; }
404 50% { opacity: 0.5; }
405 }
406</style>