this repo has no description
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 @keyframes skeleton-pulse {
205 0%, 100% { opacity: 1; }
206 50% { opacity: 0.5; }
207 }
208
209 .sessions-list {
210 display: flex;
211 flex-direction: column;
212 gap: var(--space-4);
213 }
214
215 .session-card {
216 background: var(--bg-secondary);
217 border: 1px solid var(--border-color);
218 border-radius: var(--radius-xl);
219 padding: var(--space-4);
220 display: flex;
221 justify-content: space-between;
222 align-items: center;
223 }
224
225 .session-card.current {
226 border-color: var(--accent);
227 background: var(--bg-card);
228 }
229
230 .session-header {
231 margin-bottom: var(--space-2);
232 display: flex;
233 align-items: center;
234 gap: var(--space-2);
235 flex-wrap: wrap;
236 }
237
238 .client-name {
239 font-weight: var(--font-medium);
240 color: var(--text-primary);
241 }
242
243 .badge {
244 display: inline-block;
245 padding: var(--space-1) var(--space-2);
246 border-radius: var(--radius-md);
247 font-size: var(--text-xs);
248 font-weight: var(--font-medium);
249 }
250
251 .badge.current {
252 background: var(--accent);
253 color: var(--text-inverse);
254 }
255
256 .badge.type {
257 background: var(--bg-secondary);
258 color: var(--text-secondary);
259 border: 1px solid var(--border-color);
260 }
261
262 .badge.type.oauth {
263 background: var(--success-bg);
264 color: var(--success-text);
265 border-color: var(--success-border);
266 }
267
268 .session-details {
269 display: flex;
270 flex-direction: column;
271 gap: var(--space-1);
272 }
273
274 .detail {
275 font-size: var(--text-sm);
276 }
277
278 .detail .label {
279 color: var(--text-secondary);
280 margin-right: var(--space-2);
281 }
282
283 .detail .value {
284 color: var(--text-primary);
285 }
286
287 .revoke-btn {
288 padding: var(--space-2) var(--space-4);
289 border: 1px solid var(--border-color);
290 border-radius: var(--radius-md);
291 background: transparent;
292 color: var(--text-primary);
293 cursor: pointer;
294 font-size: var(--text-sm);
295 }
296
297 .revoke-btn:hover {
298 background: var(--bg-card);
299 }
300
301 .revoke-btn.danger {
302 border-color: var(--error-text);
303 color: var(--error-text);
304 }
305
306 .revoke-btn.danger:hover {
307 background: var(--error-bg);
308 }
309
310 .actions-bar {
311 margin-top: var(--space-4);
312 display: flex;
313 gap: var(--space-2);
314 flex-wrap: wrap;
315 }
316
317 .refresh-btn {
318 padding: var(--space-2) var(--space-4);
319 background: transparent;
320 border: 1px solid var(--border-color);
321 border-radius: var(--radius-md);
322 cursor: pointer;
323 color: var(--text-primary);
324 }
325
326 .refresh-btn:hover {
327 background: var(--bg-card);
328 border-color: var(--accent);
329 }
330
331 .revoke-all-btn {
332 padding: var(--space-2) var(--space-4);
333 background: transparent;
334 border: 1px solid var(--error-text);
335 border-radius: var(--radius-md);
336 cursor: pointer;
337 color: var(--error-text);
338 }
339
340 .revoke-all-btn:hover {
341 background: var(--error-bg);
342 }
343</style>