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 .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>