this repo has no description
at main 8.4 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 { _ } from '../lib/i18n' 5 import { formatDateTime } from '../lib/date' 6 import type { Session } from '../lib/types/api' 7 import { toast } from '../lib/toast.svelte' 8 9 interface AuditEntry { 10 id: string 11 delegatedDid: string 12 actorDid: string 13 controllerDid: string | null 14 actionType: string 15 actionDetails: Record<string, unknown> | null 16 createdAt: string 17 } 18 19 const auth = $derived(getAuthState()) 20 21 function getSession(): Session | null { 22 return auth.kind === 'authenticated' ? auth.session : null 23 } 24 25 function isLoading(): boolean { 26 return auth.kind === 'loading' 27 } 28 29 const session = $derived(getSession()) 30 const authLoading = $derived(isLoading()) 31 32 let loading = $state(true) 33 let entries = $state<AuditEntry[]>([]) 34 let total = $state(0) 35 let offset = $state(0) 36 const limit = 20 37 38 $effect(() => { 39 if (!authLoading && !session) { 40 navigate(routes.login) 41 } 42 }) 43 44 $effect(() => { 45 if (session) { 46 loadAuditLog() 47 } 48 }) 49 50 async function loadAuditLog() { 51 if (!session) return 52 loading = true 53 54 try { 55 const response = await fetch( 56 `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 57 { 58 headers: { 'Authorization': `Bearer ${session.accessJwt}` } 59 } 60 ) 61 62 if (!response.ok) { 63 const data = await response.json() 64 toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog')) 65 return 66 } 67 68 const data = await response.json() 69 entries = data.entries || [] 70 total = data.total || 0 71 } catch (e) { 72 toast.error($_('delegation.failedToLoadAuditLog')) 73 } finally { 74 loading = false 75 } 76 } 77 78 function prevPage() { 79 if (offset > 0) { 80 offset = Math.max(0, offset - limit) 81 loadAuditLog() 82 } 83 } 84 85 function nextPage() { 86 if (offset + limit < total) { 87 offset = offset + limit 88 loadAuditLog() 89 } 90 } 91 92 function formatActionType(type: string): string { 93 const labels: Record<string, string> = { 94 'GrantCreated': $_('delegation.actionGrantCreated'), 95 'GrantRevoked': $_('delegation.actionGrantRevoked'), 96 'ScopesModified': $_('delegation.actionScopesModified'), 97 'TokenIssued': $_('delegation.actionTokenIssued'), 98 'RepoWrite': $_('delegation.actionRepoWrite'), 99 'BlobUpload': $_('delegation.actionBlobUpload'), 100 'AccountAction': $_('delegation.actionAccountAction') 101 } 102 return labels[type] || type 103 } 104 105 function formatActionDetails(details: Record<string, unknown> | null): string { 106 if (!details) return '' 107 return Object.entries(details) 108 .map(([key, value]) => `${key.replace(/_/g, ' ')}: ${JSON.stringify(value)}`) 109 .join(', ') 110 } 111 112 function truncateDid(did: string): string { 113 if (did.length <= 30) return did 114 return did.substring(0, 20) + '...' + did.substring(did.length - 6) 115 } 116</script> 117 118<div class="page"> 119 <header> 120 <a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a> 121 <h1>{$_('delegation.auditLogTitle')}</h1> 122 </header> 123 124 {#if loading} 125 <div class="skeleton-list"> 126 {#each Array(3) as _} 127 <div class="skeleton-entry"></div> 128 {/each} 129 </div> 130 {:else} 131 {#if entries.length === 0} 132 <p class="empty">{$_('delegation.noActivity')}</p> 133 {:else} 134 <div class="audit-list"> 135 {#each entries as entry} 136 <div class="audit-entry"> 137 <div class="entry-header"> 138 <span class="action-type">{formatActionType(entry.actionType)}</span> 139 <span class="timestamp">{formatDateTime(entry.createdAt)}</span> 140 </div> 141 <div class="entry-details"> 142 <div class="detail"> 143 <span class="label">{$_('delegation.actor')}</span> 144 <span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span> 145 </div> 146 {#if entry.controllerDid} 147 <div class="detail"> 148 <span class="label">{$_('delegation.controller')}</span> 149 <span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span> 150 </div> 151 {/if} 152 <div class="detail"> 153 <span class="label">{$_('delegation.account')}</span> 154 <span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span> 155 </div> 156 {#if entry.actionDetails} 157 <div class="detail"> 158 <span class="label">{$_('delegation.details')}</span> 159 <span class="value details">{formatActionDetails(entry.actionDetails)}</span> 160 </div> 161 {/if} 162 </div> 163 </div> 164 {/each} 165 </div> 166 167 <div class="pagination"> 168 <button 169 class="ghost" 170 onclick={prevPage} 171 disabled={offset === 0} 172 > 173 {$_('delegation.previous')} 174 </button> 175 <span class="page-info"> 176 {$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })} 177 </span> 178 <button 179 class="ghost" 180 onclick={nextPage} 181 disabled={offset + limit >= total} 182 > 183 {$_('delegation.next')} 184 </button> 185 </div> 186 {/if} 187 188 <div class="actions-bar"> 189 <button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button> 190 </div> 191 {/if} 192</div> 193 194<style> 195 .page { 196 max-width: var(--width-lg); 197 margin: 0 auto; 198 padding: var(--space-7); 199 } 200 201 header { 202 margin-bottom: var(--space-7); 203 } 204 205 .back { 206 color: var(--text-secondary); 207 text-decoration: none; 208 font-size: var(--text-sm); 209 } 210 211 .back:hover { 212 color: var(--accent); 213 } 214 215 h1 { 216 margin: var(--space-2) 0 0 0; 217 } 218 219 .empty { 220 text-align: center; 221 color: var(--text-secondary); 222 padding: var(--space-7); 223 } 224 225 .audit-list { 226 display: flex; 227 flex-direction: column; 228 gap: var(--space-3); 229 margin-bottom: var(--space-4); 230 } 231 232 .audit-entry { 233 background: var(--bg-secondary); 234 border: 1px solid var(--border-color); 235 border-radius: var(--radius-lg); 236 padding: var(--space-4); 237 } 238 239 .entry-header { 240 display: flex; 241 justify-content: space-between; 242 align-items: center; 243 margin-bottom: var(--space-3); 244 flex-wrap: wrap; 245 gap: var(--space-2); 246 } 247 248 .action-type { 249 font-weight: var(--font-semibold); 250 color: var(--text-primary); 251 } 252 253 .timestamp { 254 font-size: var(--text-sm); 255 color: var(--text-muted); 256 } 257 258 .entry-details { 259 display: flex; 260 flex-direction: column; 261 gap: var(--space-2); 262 } 263 264 .detail { 265 font-size: var(--text-sm); 266 display: flex; 267 gap: var(--space-2); 268 align-items: baseline; 269 flex-wrap: wrap; 270 } 271 272 .detail .label { 273 color: var(--text-secondary); 274 min-width: 80px; 275 } 276 277 .detail .value { 278 color: var(--text-primary); 279 } 280 281 .detail .value.did { 282 font-family: var(--font-mono); 283 font-size: var(--text-xs); 284 word-break: break-all; 285 } 286 287 .detail .value.details { 288 font-size: var(--text-xs); 289 color: var(--text-muted); 290 word-break: break-word; 291 } 292 293 .pagination { 294 display: flex; 295 justify-content: center; 296 align-items: center; 297 gap: var(--space-4); 298 margin: var(--space-5) 0; 299 } 300 301 .pagination button { 302 padding: var(--space-2) var(--space-4); 303 font-size: var(--text-sm); 304 } 305 306 .page-info { 307 font-size: var(--text-sm); 308 color: var(--text-secondary); 309 } 310 311 .actions-bar { 312 display: flex; 313 gap: var(--space-2); 314 flex-wrap: wrap; 315 } 316 317 .actions-bar button { 318 padding: var(--space-2) var(--space-4); 319 font-size: var(--text-sm); 320 } 321 322 .skeleton-list { 323 display: flex; 324 flex-direction: column; 325 gap: var(--space-3); 326 } 327 328 .skeleton-entry { 329 height: 100px; 330 background: var(--bg-secondary); 331 border: 1px solid var(--border-color); 332 border-radius: var(--radius-lg); 333 animation: skeleton-pulse 1.5s ease-in-out infinite; 334 } 335 336 @keyframes skeleton-pulse { 337 0%, 100% { opacity: 1; } 338 50% { opacity: 0.5; } 339 } 340</style>