this repo has no description
at main 10 kB view raw
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 .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>