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 &#9998; 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>