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>