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