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