forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
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 const auth = $derived(getAuthState())
11
12 function getSession(): Session | null {
13 return auth.kind === 'authenticated' ? auth.session : null
14 }
15
16 function isLoading(): boolean {
17 return auth.kind === 'loading'
18 }
19
20 const session = $derived(getSession())
21 const authLoading = $derived(isLoading())
22 let loading = $state(true)
23 let sessions = $state<Array<{
24 id: string
25 sessionType: string
26 clientName: string | null
27 createdAt: string
28 expiresAt: string
29 isCurrent: boolean
30 }>>([])
31 $effect(() => {
32 if (!authLoading && !session) {
33 navigate(routes.login)
34 }
35 })
36 $effect(() => {
37 if (session) {
38 loadSessions()
39 }
40 })
41 async function loadSessions() {
42 if (!session) return
43 loading = true
44 try {
45 const result = await api.listSessions(session.accessJwt)
46 sessions = result.sessions
47 } catch (e) {
48 toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToLoad'))
49 } finally {
50 loading = false
51 }
52 }
53 async function revokeSession(sessionId: string, isCurrent: boolean) {
54 if (!session) return
55 const msg = isCurrent
56 ? $_('sessions.revokeCurrentConfirm')
57 : $_('sessions.revokeConfirm')
58 if (!confirm(msg)) return
59 try {
60 await api.revokeSession(session.accessJwt, sessionId)
61 if (isCurrent) {
62 navigate(routes.login)
63 } else {
64 sessions = sessions.filter(s => s.id !== sessionId)
65 toast.success($_('sessions.sessionRevoked'))
66 }
67 } catch (e) {
68 toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevoke'))
69 }
70 }
71 async function revokeAllSessions() {
72 if (!session) return
73 const otherSessions = sessions.filter(s => !s.isCurrent)
74 if (otherSessions.length === 0) {
75 toast.warning($_('sessions.noOtherSessions'))
76 return
77 }
78 if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return
79 try {
80 await api.revokeAllSessions(session.accessJwt)
81 sessions = sessions.filter(s => s.isCurrent)
82 toast.success($_('sessions.allSessionsRevoked'))
83 } catch (e) {
84 toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll'))
85 }
86 }
87 function formatDate(dateStr: string): string {
88 return formatDateTime(dateStr)
89 }
90 function timeAgo(dateStr: string): string {
91 const date = new Date(dateStr)
92 const now = new Date()
93 const diff = now.getTime() - date.getTime()
94 const days = Math.floor(diff / (1000 * 60 * 60 * 24))
95 const hours = Math.floor(diff / (1000 * 60 * 60))
96 const minutes = Math.floor(diff / (1000 * 60))
97 if (days > 0) return $_('sessions.daysAgo', { values: { count: days } })
98 if (hours > 0) return $_('sessions.hoursAgo', { values: { count: hours } })
99 if (minutes > 0) return $_('sessions.minutesAgo', { values: { count: minutes } })
100 return $_('sessions.justNow')
101 }
102</script>
103<div class="page">
104 <header>
105 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
106 <h1>{$_('sessions.title')}</h1>
107 </header>
108 {#if loading}
109 <div class="sessions-list">
110 {#each Array(3) as _}
111 <div class="skeleton-card"></div>
112 {/each}
113 </div>
114 {:else}
115 {#if sessions.length === 0}
116 <p class="empty">{$_('sessions.noSessions')}</p>
117 {:else}
118 <div class="sessions-list">
119 {#each sessions as session}
120 <div class="session-card" class:current={session.isCurrent}>
121 <div class="session-info">
122 <div class="session-header">
123 {#if session.isCurrent}
124 <span class="badge current">{$_('sessions.current')}</span>
125 {/if}
126 <span class="badge type" class:oauth={session.sessionType === 'oauth'}>
127 {session.sessionType === 'oauth' ? $_('sessions.oauth') : $_('sessions.session')}
128 </span>
129 {#if session.clientName}
130 <span class="client-name">{session.clientName}</span>
131 {/if}
132 </div>
133 <div class="session-details">
134 <div class="detail">
135 <span class="label">{$_('sessions.created')}</span>
136 <span class="value">{timeAgo(session.createdAt)}</span>
137 </div>
138 <div class="detail">
139 <span class="label">{$_('sessions.expires')}</span>
140 <span class="value">{formatDate(session.expiresAt)}</span>
141 </div>
142 </div>
143 </div>
144 <div class="session-actions">
145 <button
146 class="revoke-btn"
147 class:danger={!session.isCurrent}
148 onclick={() => revokeSession(session.id, session.isCurrent)}
149 >
150 {session.isCurrent ? $_('sessions.signOut') : $_('sessions.revoke')}
151 </button>
152 </div>
153 </div>
154 {/each}
155 </div>
156 <div class="actions-bar">
157 <button class="refresh-btn" onclick={loadSessions}>{$_('common.refresh')}</button>
158 {#if sessions.filter(s => !s.isCurrent).length > 0}
159 <button class="revoke-all-btn" onclick={revokeAllSessions}>{$_('sessions.revokeAll')}</button>
160 {/if}
161 </div>
162 {/if}
163 {/if}
164</div>
165<style>
166 .page {
167 max-width: var(--width-lg);
168 margin: 0 auto;
169 padding: var(--space-7);
170 }
171
172 header {
173 margin-bottom: var(--space-7);
174 }
175
176 .back {
177 color: var(--text-secondary);
178 text-decoration: none;
179 font-size: var(--text-sm);
180 }
181
182 .back:hover {
183 color: var(--accent);
184 }
185
186 h1 {
187 margin: var(--space-2) 0 0 0;
188 }
189
190 .empty {
191 text-align: center;
192 color: var(--text-secondary);
193 padding: var(--space-7);
194 }
195
196 .skeleton-card {
197 height: 80px;
198 background: var(--bg-secondary);
199 border: 1px solid var(--border-color);
200 border-radius: var(--radius-xl);
201 animation: skeleton-pulse 1.5s ease-in-out infinite;
202 }
203
204 .sessions-list {
205 display: flex;
206 flex-direction: column;
207 gap: var(--space-4);
208 }
209
210 .session-card {
211 background: var(--bg-secondary);
212 border: 1px solid var(--border-color);
213 border-radius: var(--radius-xl);
214 padding: var(--space-4);
215 display: flex;
216 justify-content: space-between;
217 align-items: center;
218 }
219
220 .session-card.current {
221 border-color: var(--accent);
222 background: var(--bg-card);
223 }
224
225 .session-header {
226 margin-bottom: var(--space-2);
227 display: flex;
228 align-items: center;
229 gap: var(--space-2);
230 flex-wrap: wrap;
231 }
232
233 .client-name {
234 font-weight: var(--font-medium);
235 color: var(--text-primary);
236 }
237
238 .badge {
239 display: inline-block;
240 padding: var(--space-1) var(--space-2);
241 border-radius: var(--radius-md);
242 font-size: var(--text-xs);
243 font-weight: var(--font-medium);
244 }
245
246 .badge.current {
247 background: var(--accent);
248 color: var(--text-inverse);
249 }
250
251 .badge.type {
252 background: var(--bg-secondary);
253 color: var(--text-secondary);
254 border: 1px solid var(--border-color);
255 }
256
257 .badge.type.oauth {
258 background: var(--success-bg);
259 color: var(--success-text);
260 border-color: var(--success-border);
261 }
262
263 .session-details {
264 display: flex;
265 flex-direction: column;
266 gap: var(--space-1);
267 }
268
269 .detail {
270 font-size: var(--text-sm);
271 }
272
273 .detail .label {
274 color: var(--text-secondary);
275 margin-right: var(--space-2);
276 }
277
278 .detail .value {
279 color: var(--text-primary);
280 }
281
282 .revoke-btn {
283 padding: var(--space-2) var(--space-4);
284 border: 1px solid var(--border-color);
285 border-radius: var(--radius-md);
286 background: transparent;
287 color: var(--text-primary);
288 cursor: pointer;
289 font-size: var(--text-sm);
290 }
291
292 .revoke-btn:hover {
293 background: var(--bg-card);
294 }
295
296 .revoke-btn.danger {
297 border-color: var(--error-text);
298 color: var(--error-text);
299 }
300
301 .revoke-btn.danger:hover {
302 background: var(--error-bg);
303 }
304
305 .actions-bar {
306 margin-top: var(--space-4);
307 display: flex;
308 gap: var(--space-2);
309 flex-wrap: wrap;
310 }
311
312 .refresh-btn {
313 padding: var(--space-2) var(--space-4);
314 background: transparent;
315 border: 1px solid var(--border-color);
316 border-radius: var(--radius-md);
317 cursor: pointer;
318 color: var(--text-primary);
319 }
320
321 .refresh-btn:hover {
322 background: var(--bg-card);
323 border-color: var(--accent);
324 }
325
326 .revoke-all-btn {
327 padding: var(--space-2) var(--space-4);
328 background: transparent;
329 border: 1px solid var(--error-text);
330 border-radius: var(--radius-md);
331 cursor: pointer;
332 color: var(--error-text);
333 }
334
335 .revoke-all-btn:hover {
336 background: var(--error-bg);
337 }
338</style>