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>