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 6 interface TrustedDevice { 7 id: string 8 userAgent: string | null 9 friendlyName: string | null 10 trustedAt: string | null 11 trustedUntil: string | null 12 lastSeenAt: string 13 } 14 15 const auth = getAuthState() 16 let devices = $state<TrustedDevice[]>([]) 17 let loading = $state(true) 18 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 19 let editingDeviceId = $state<string | null>(null) 20 let editDeviceName = $state('') 21 22 $effect(() => { 23 if (!auth.loading && !auth.session) { 24 navigate('/login') 25 } 26 }) 27 28 $effect(() => { 29 if (auth.session) { 30 loadDevices() 31 } 32 }) 33 34 async function loadDevices() { 35 if (!auth.session) return 36 loading = true 37 try { 38 const result = await api.listTrustedDevices(auth.session.accessJwt) 39 devices = result.devices 40 } catch { 41 showMessage('error', 'Failed to load trusted devices') 42 } finally { 43 loading = false 44 } 45 } 46 47 function showMessage(type: 'success' | 'error', text: string) { 48 message = { type, text } 49 setTimeout(() => { 50 if (message?.text === text) message = null 51 }, 5000) 52 } 53 54 async function handleRevoke(deviceId: string) { 55 if (!auth.session) return 56 if (!confirm('Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.')) return 57 try { 58 await api.revokeTrustedDevice(auth.session.accessJwt, deviceId) 59 await loadDevices() 60 showMessage('success', 'Device trust revoked') 61 } catch (e) { 62 showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device') 63 } 64 } 65 66 function startEditDevice(device: TrustedDevice) { 67 editingDeviceId = device.id 68 editDeviceName = device.friendlyName || '' 69 } 70 71 function cancelEditDevice() { 72 editingDeviceId = null 73 editDeviceName = '' 74 } 75 76 async function handleSaveDeviceName() { 77 if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return 78 try { 79 await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim()) 80 await loadDevices() 81 editingDeviceId = null 82 editDeviceName = '' 83 showMessage('success', 'Device renamed') 84 } catch (e) { 85 showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device') 86 } 87 } 88 89 function formatDate(dateStr: string): string { 90 return new Date(dateStr).toLocaleDateString(undefined, { 91 year: 'numeric', 92 month: 'short', 93 day: 'numeric', 94 hour: '2-digit', 95 minute: '2-digit' 96 }) 97 } 98 99 function parseUserAgent(ua: string | null): string { 100 if (!ua) return 'Unknown device' 101 if (ua.includes('Firefox')) return 'Firefox' 102 if (ua.includes('Chrome')) return 'Chrome' 103 if (ua.includes('Safari')) return 'Safari' 104 if (ua.includes('Edge')) return 'Edge' 105 return 'Browser' 106 } 107 108 function getDaysRemaining(trustedUntil: string | null): number { 109 if (!trustedUntil) return 0 110 const now = new Date() 111 const until = new Date(trustedUntil) 112 const diff = until.getTime() - now.getTime() 113 return Math.ceil(diff / (1000 * 60 * 60 * 24)) 114 } 115</script> 116 117<div class="page"> 118 <header> 119 <a href="#/security" class="back">&larr; Security Settings</a> 120 <h1>Trusted Devices</h1> 121 </header> 122 123 {#if message} 124 <div class="message {message.type}">{message.text}</div> 125 {/if} 126 127 <div class="description"> 128 <p> 129 Trusted devices can skip two-factor authentication when logging in. 130 Trust is granted for 30 days and automatically extends when you use the device. 131 </p> 132 </div> 133 134 {#if loading} 135 <div class="loading">Loading...</div> 136 {:else if devices.length === 0} 137 <div class="empty-state"> 138 <p>No trusted devices yet.</p> 139 <p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</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="Device name" 152 /> 153 <div class="edit-actions"> 154 <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button> 155 <button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button> 156 </div> 157 {:else} 158 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3> 159 <button class="btn-icon" onclick={() => startEditDevice(device)} title="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">Browser:</span> {device.userAgent}</p> 168 {:else if device.userAgent} 169 <p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p> 170 {/if} 171 <p class="detail"> 172 <span class="label">Last seen:</span> {formatDate(device.lastSeenAt)} 173 </p> 174 {#if device.trustedAt} 175 <p class="detail"> 176 <span class="label">Trusted since:</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">Trust expires:</span> 183 {#if daysRemaining <= 0} 184 Expired 185 {:else if daysRemaining === 1} 186 Tomorrow 187 {:else} 188 In {daysRemaining} days 189 {/if} 190 </p> 191 {/if} 192 </div> 193 194 <div class="device-actions"> 195 <button class="btn-danger" onclick={() => handleRevoke(device.id)}> 196 Revoke Trust 197 </button> 198 </div> 199 </div> 200 {/each} 201 </div> 202 {/if} 203</div> 204 205<style> 206 .page { 207 max-width: 600px; 208 margin: 0 auto; 209 padding: 2rem 1rem; 210 } 211 212 header { 213 margin-bottom: 2rem; 214 } 215 216 .back { 217 display: inline-block; 218 margin-bottom: 1rem; 219 color: var(--accent); 220 text-decoration: none; 221 font-size: 0.875rem; 222 } 223 224 .back:hover { 225 text-decoration: underline; 226 } 227 228 h1 { 229 margin: 0; 230 font-size: 1.75rem; 231 } 232 233 .message { 234 padding: 0.75rem 1rem; 235 border-radius: 4px; 236 margin-bottom: 1rem; 237 } 238 239 .message.success { 240 background: var(--success-bg); 241 color: var(--success-text); 242 border: 1px solid var(--success-border); 243 } 244 245 .message.error { 246 background: var(--error-bg); 247 color: var(--error-text); 248 border: 1px solid var(--error-border); 249 } 250 251 .description { 252 background: var(--bg-card); 253 border: 1px solid var(--border-color); 254 border-radius: 8px; 255 padding: 1rem; 256 margin-bottom: 1.5rem; 257 } 258 259 .description p { 260 margin: 0; 261 color: var(--text-secondary); 262 font-size: 0.9rem; 263 } 264 265 .loading { 266 text-align: center; 267 padding: 2rem; 268 color: var(--text-secondary); 269 } 270 271 .empty-state { 272 text-align: center; 273 padding: 3rem 1rem; 274 background: var(--bg-card); 275 border: 1px solid var(--border-color); 276 border-radius: 8px; 277 } 278 279 .empty-state p { 280 margin: 0; 281 color: var(--text-secondary); 282 } 283 284 .empty-state .hint { 285 margin-top: 0.5rem; 286 font-size: 0.875rem; 287 color: var(--text-muted); 288 } 289 290 .device-list { 291 display: flex; 292 flex-direction: column; 293 gap: 1rem; 294 } 295 296 .device-card { 297 background: var(--bg-card); 298 border: 1px solid var(--border-color); 299 border-radius: 8px; 300 padding: 1rem; 301 } 302 303 .device-header { 304 display: flex; 305 align-items: center; 306 gap: 0.5rem; 307 margin-bottom: 0.75rem; 308 } 309 310 .device-header h3 { 311 margin: 0; 312 flex: 1; 313 font-size: 1rem; 314 } 315 316 .edit-name-input { 317 flex: 1; 318 padding: 0.5rem; 319 border: 1px solid var(--border-color); 320 border-radius: 4px; 321 background: var(--bg-input); 322 color: var(--text-primary); 323 font-size: 0.9rem; 324 } 325 326 .edit-actions { 327 display: flex; 328 gap: 0.5rem; 329 } 330 331 .btn-icon { 332 background: none; 333 border: none; 334 color: var(--text-secondary); 335 cursor: pointer; 336 padding: 0.25rem; 337 font-size: 1rem; 338 } 339 340 .btn-icon:hover { 341 color: var(--text-primary); 342 } 343 344 .device-details { 345 margin-bottom: 0.75rem; 346 } 347 348 .detail { 349 margin: 0.25rem 0; 350 font-size: 0.875rem; 351 color: var(--text-secondary); 352 } 353 354 .detail .label { 355 color: var(--text-muted); 356 } 357 358 .trust-expiry.expiring-soon { 359 color: var(--warning-text); 360 } 361 362 .device-actions { 363 display: flex; 364 justify-content: flex-end; 365 padding-top: 0.75rem; 366 border-top: 1px solid var(--border-color); 367 } 368 369 .btn-small { 370 padding: 0.375rem 0.75rem; 371 border-radius: 4px; 372 font-size: 0.8rem; 373 cursor: pointer; 374 } 375 376 .btn-primary { 377 background: var(--accent); 378 color: white; 379 border: none; 380 } 381 382 .btn-primary:hover { 383 background: var(--accent-hover); 384 } 385 386 .btn-secondary { 387 background: var(--bg-input); 388 border: 1px solid var(--border-color); 389 color: var(--text-secondary); 390 } 391 392 .btn-secondary:hover { 393 background: var(--bg-secondary); 394 } 395 396 .btn-danger { 397 background: transparent; 398 border: 1px solid var(--error-border); 399 color: var(--error-text); 400 padding: 0.5rem 1rem; 401 border-radius: 4px; 402 cursor: pointer; 403 font-size: 0.875rem; 404 } 405 406 .btn-danger:hover { 407 background: var(--error-bg); 408 } 409</style>