Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / frontend / src / routes / TrustedDevices.svelte
8.7 kB 327 lines
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="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 155 <button class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 156 </div> 157 {:else} 158 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3> 159 <button class="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="sm danger-outline" 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 header { 207 margin-bottom: var(--space-7); 208 } 209 210 .back { 211 display: inline-block; 212 margin-bottom: var(--space-4); 213 color: var(--accent); 214 text-decoration: none; 215 font-size: var(--text-sm); 216 } 217 218 .back:hover { 219 text-decoration: underline; 220 } 221 222 h1 { 223 margin: 0; 224 font-size: var(--text-2xl); 225 } 226 227 .description { 228 background: var(--bg-card); 229 border: 1px solid var(--border-color); 230 border-radius: var(--radius-xl); 231 padding: var(--space-4); 232 margin-bottom: var(--space-6); 233 } 234 235 .description p { 236 margin: 0; 237 color: var(--text-secondary); 238 font-size: var(--text-sm); 239 } 240 241 .empty-state { 242 text-align: center; 243 padding: var(--space-8) var(--space-4); 244 background: var(--bg-card); 245 border: 1px solid var(--border-color); 246 border-radius: var(--radius-xl); 247 } 248 249 .empty-state p { 250 margin: 0; 251 color: var(--text-secondary); 252 } 253 254 .empty-state .hint { 255 margin-top: var(--space-2); 256 font-size: var(--text-sm); 257 color: var(--text-muted); 258 } 259 260 .device-list { 261 display: flex; 262 flex-direction: column; 263 gap: var(--space-4); 264 } 265 266 .device-card { 267 background: var(--bg-card); 268 border: 1px solid var(--border-color); 269 border-radius: var(--radius-xl); 270 padding: var(--space-4); 271 } 272 273 .device-header { 274 display: flex; 275 align-items: center; 276 gap: var(--space-2); 277 margin-bottom: var(--space-3); 278 } 279 280 .device-header h3 { 281 margin: 0; 282 flex: 1; 283 font-size: var(--text-base); 284 } 285 286 .edit-name-input { 287 flex: 1; 288 padding: var(--space-2); 289 font-size: var(--text-sm); 290 } 291 292 .edit-actions { 293 display: flex; 294 gap: var(--space-2); 295 } 296 297 .device-details { 298 margin-bottom: var(--space-3); 299 } 300 301 .detail { 302 margin: var(--space-1) 0; 303 font-size: var(--text-sm); 304 color: var(--text-secondary); 305 } 306 307 .detail .label { 308 color: var(--text-muted); 309 } 310 311 .trust-expiry.expiring-soon { 312 color: var(--warning-text); 313 } 314 315 .device-actions { 316 display: flex; 317 justify-content: flex-end; 318 padding-top: var(--space-3); 319 border-top: 1px solid var(--border-color); 320 } 321 322 .skeleton-list { 323 display: flex; 324 flex-direction: column; 325 gap: var(--space-4); 326 } 327</style>