this repo has no description
at main 9.2 kB view raw
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>