forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
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 ✎
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>