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 { _ } 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 .loading, 220 .empty { 221 text-align: center; 222 color: var(--text-secondary); 223 padding: var(--space-7); 224 } 225 226 .message.error { 227 padding: var(--space-3); 228 background: var(--error-bg); 229 border: 1px solid var(--error-border); 230 border-radius: var(--radius-md); 231 color: var(--error-text); 232 margin-bottom: var(--space-4); 233 } 234 235 .audit-list { 236 display: flex; 237 flex-direction: column; 238 gap: var(--space-3); 239 margin-bottom: var(--space-4); 240 } 241 242 .audit-entry { 243 background: var(--bg-secondary); 244 border: 1px solid var(--border-color); 245 border-radius: var(--radius-lg); 246 padding: var(--space-4); 247 } 248 249 .entry-header { 250 display: flex; 251 justify-content: space-between; 252 align-items: center; 253 margin-bottom: var(--space-3); 254 flex-wrap: wrap; 255 gap: var(--space-2); 256 } 257 258 .action-type { 259 font-weight: var(--font-semibold); 260 color: var(--text-primary); 261 } 262 263 .timestamp { 264 font-size: var(--text-sm); 265 color: var(--text-muted); 266 } 267 268 .entry-details { 269 display: flex; 270 flex-direction: column; 271 gap: var(--space-2); 272 } 273 274 .detail { 275 font-size: var(--text-sm); 276 display: flex; 277 gap: var(--space-2); 278 align-items: baseline; 279 flex-wrap: wrap; 280 } 281 282 .detail .label { 283 color: var(--text-secondary); 284 min-width: 80px; 285 } 286 287 .detail .value { 288 color: var(--text-primary); 289 } 290 291 .detail .value.did { 292 font-family: var(--font-mono); 293 font-size: var(--text-xs); 294 word-break: break-all; 295 } 296 297 .detail .value.details { 298 font-size: var(--text-xs); 299 color: var(--text-muted); 300 word-break: break-word; 301 } 302 303 .pagination { 304 display: flex; 305 justify-content: center; 306 align-items: center; 307 gap: var(--space-4); 308 margin: var(--space-5) 0; 309 } 310 311 .pagination button { 312 padding: var(--space-2) var(--space-4); 313 font-size: var(--text-sm); 314 } 315 316 .page-info { 317 font-size: var(--text-sm); 318 color: var(--text-secondary); 319 } 320 321 .actions-bar { 322 display: flex; 323 gap: var(--space-2); 324 flex-wrap: wrap; 325 } 326 327 .actions-bar button { 328 padding: var(--space-2) var(--space-4); 329 font-size: var(--text-sm); 330 } 331 332 .skeleton-list { 333 display: flex; 334 flex-direction: column; 335 gap: var(--space-3); 336 } 337 338 .skeleton-entry { 339 height: 100px; 340 background: var(--bg-secondary); 341 border: 1px solid var(--border-color); 342 border-radius: var(--radius-lg); 343 animation: skeleton-pulse 1.5s ease-in-out infinite; 344 } 345 346 @keyframes skeleton-pulse { 347 0%, 100% { opacity: 1; } 348 50% { opacity: 0.5; } 349 } 350</style>