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 .empty-state {
248 text-align: center;
249 padding: var(--space-8) var(--space-4);
250 background: var(--bg-card);
251 border: 1px solid var(--border-color);
252 border-radius: var(--radius-xl);
253 }
254
255 .empty-state p {
256 margin: 0;
257 color: var(--text-secondary);
258 }
259
260 .empty-state .hint {
261 margin-top: var(--space-2);
262 font-size: var(--text-sm);
263 color: var(--text-muted);
264 }
265
266 .device-list {
267 display: flex;
268 flex-direction: column;
269 gap: var(--space-4);
270 }
271
272 .device-card {
273 background: var(--bg-card);
274 border: 1px solid var(--border-color);
275 border-radius: var(--radius-xl);
276 padding: var(--space-4);
277 }
278
279 .device-header {
280 display: flex;
281 align-items: center;
282 gap: var(--space-2);
283 margin-bottom: var(--space-3);
284 }
285
286 .device-header h3 {
287 margin: 0;
288 flex: 1;
289 font-size: var(--text-base);
290 }
291
292 .edit-name-input {
293 flex: 1;
294 padding: var(--space-2);
295 font-size: var(--text-sm);
296 }
297
298 .edit-actions {
299 display: flex;
300 gap: var(--space-2);
301 }
302
303 .btn-icon {
304 background: none;
305 border: none;
306 color: var(--text-secondary);
307 cursor: pointer;
308 padding: var(--space-1);
309 font-size: var(--text-base);
310 }
311
312 .btn-icon:hover {
313 color: var(--text-primary);
314 }
315
316 .device-details {
317 margin-bottom: var(--space-3);
318 }
319
320 .detail {
321 margin: var(--space-1) 0;
322 font-size: var(--text-sm);
323 color: var(--text-secondary);
324 }
325
326 .detail .label {
327 color: var(--text-muted);
328 }
329
330 .trust-expiry.expiring-soon {
331 color: var(--warning-text);
332 }
333
334 .device-actions {
335 display: flex;
336 justify-content: flex-end;
337 padding-top: var(--space-3);
338 border-top: 1px solid var(--border-color);
339 }
340
341 .btn-small {
342 padding: var(--space-2) var(--space-3);
343 border-radius: var(--radius-md);
344 font-size: var(--text-xs);
345 cursor: pointer;
346 }
347
348 .btn-primary {
349 background: var(--accent);
350 color: var(--text-inverse);
351 border: none;
352 }
353
354 .btn-primary:hover {
355 background: var(--accent-hover);
356 }
357
358 .btn-secondary {
359 background: var(--bg-input);
360 border: 1px solid var(--border-color);
361 color: var(--text-secondary);
362 }
363
364 .btn-secondary:hover {
365 background: var(--bg-secondary);
366 }
367
368 .btn-danger {
369 background: transparent;
370 border: 1px solid var(--error-border);
371 color: var(--error-text);
372 padding: var(--space-2) var(--space-4);
373 border-radius: var(--radius-md);
374 cursor: pointer;
375 font-size: var(--text-sm);
376 }
377
378 .btn-danger:hover {
379 background: var(--error-bg);
380 }
381
382 .skeleton-list {
383 display: flex;
384 flex-direction: column;
385 gap: var(--space-4);
386 }
387
388 .skeleton-card {
389 height: 100px;
390 background: var(--bg-secondary);
391 border: 1px solid var(--border-color);
392 border-radius: var(--radius-xl);
393 animation: skeleton-pulse 1.5s ease-in-out infinite;
394 }
395
396 @keyframes skeleton-pulse {
397 0%, 100% { opacity: 1; }
398 50% { opacity: 0.5; }
399 }
400</style>