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