@kyro-cms/admin 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +149 -51
  2. package/package.json +52 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +136 -27
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +1417 -661
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +3 -3
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +199 -57
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +786 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +191 -53
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +149 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/UserManagement.tsx +204 -0
  26. package/src/components/VersionHistoryPanel.tsx +3 -3
  27. package/src/components/WebhookManager.tsx +608 -0
  28. package/src/components/blocks/AccordionBlock.tsx +97 -0
  29. package/src/components/blocks/ArrayBlock.tsx +75 -0
  30. package/src/components/blocks/BlockEditModal.MARKER +12 -0
  31. package/src/components/blocks/BlockEditModal.tsx +774 -0
  32. package/src/components/blocks/ButtonBlock.tsx +165 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +66 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +151 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +64 -0
  38. package/src/components/blocks/HeadingBlock.tsx +81 -0
  39. package/src/components/blocks/HeroBlock.tsx +157 -0
  40. package/src/components/blocks/ImageBlock.tsx +83 -0
  41. package/src/components/blocks/LinkBlock.tsx +71 -0
  42. package/src/components/blocks/ListBlock.tsx +39 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +279 -0
  45. package/src/components/blocks/VStackBlock.tsx +75 -0
  46. package/src/components/blocks/VideoBlock.tsx +45 -0
  47. package/src/components/blocks/index.ts +10 -0
  48. package/src/components/fields/BlocksField.tsx +323 -0
  49. package/src/components/fields/CheckboxField.tsx +15 -9
  50. package/src/components/fields/CodeField.tsx +234 -0
  51. package/src/components/fields/DateField.tsx +38 -11
  52. package/src/components/fields/EditorClient.tsx +271 -0
  53. package/src/components/fields/FileField.tsx +390 -0
  54. package/src/components/fields/HybridContentField.tsx +109 -0
  55. package/src/components/fields/ImageField.tsx +429 -0
  56. package/src/components/fields/JSONField.tsx +361 -0
  57. package/src/components/fields/MarkdownField.tsx +282 -0
  58. package/src/components/fields/NumberField.tsx +42 -12
  59. package/src/components/fields/PortableTextField.tsx +143 -0
  60. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  61. package/src/components/fields/RelationshipField.tsx +231 -59
  62. package/src/components/fields/SelectField.tsx +25 -15
  63. package/src/components/fields/TextField.tsx +45 -14
  64. package/src/components/fields/extensions/blockComponents.tsx +237 -0
  65. package/src/components/fields/extensions/blocksStore.ts +273 -0
  66. package/src/components/fields/index.ts +13 -0
  67. package/src/components/index.ts +1 -2
  68. package/src/components/layout/Header.tsx +2 -2
  69. package/src/components/layout/Layout.tsx +2 -2
  70. package/src/components/ui/Badge.tsx +9 -4
  71. package/src/components/ui/BlockDrawer.tsx +79 -0
  72. package/src/components/ui/Button.tsx +1 -1
  73. package/src/components/ui/CommandPalette.tsx +362 -0
  74. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  75. package/src/components/ui/Dropdown.tsx +1 -1
  76. package/src/components/ui/Modal.tsx +37 -12
  77. package/src/components/ui/PromptModal.tsx +94 -0
  78. package/src/components/ui/SlidePanel.tsx +43 -16
  79. package/src/components/ui/Toast.tsx +80 -14
  80. package/src/env.d.ts +16 -0
  81. package/src/env.ts +20 -0
  82. package/src/index.ts +0 -1
  83. package/src/layouts/AdminLayout.astro +164 -170
  84. package/src/layouts/AuthLayout.astro +50 -0
  85. package/src/lib/MediaService.ts +541 -0
  86. package/src/lib/auth/sqlite-adapter.ts +319 -0
  87. package/src/lib/config.ts +22 -6
  88. package/src/lib/dataStore.ts +132 -74
  89. package/src/lib/db/adapter.ts +54 -0
  90. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  91. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  92. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  93. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  94. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  95. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  96. package/src/lib/db/index.ts +449 -0
  97. package/src/lib/db/mongodb-adapter.ts +207 -0
  98. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  99. package/src/lib/db/schema/mysql-auth.ts +113 -0
  100. package/src/lib/db/schema/mysql-content.ts +20 -0
  101. package/src/lib/db/schema/postgres-auth.ts +116 -0
  102. package/src/lib/db/schema/postgres-content.ts +35 -0
  103. package/src/lib/db/schema/postgres-media.ts +52 -0
  104. package/src/lib/db/schema/postgres-settings.ts +11 -0
  105. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  106. package/src/lib/db/schema/sqlite-content.ts +20 -0
  107. package/src/lib/graphql/index.ts +1 -0
  108. package/src/lib/graphql/schema.ts +443 -0
  109. package/src/lib/rate-limit.ts +267 -0
  110. package/src/lib/storage.ts +374 -0
  111. package/src/lib/store.ts +85 -0
  112. package/src/middleware.ts +116 -28
  113. package/src/pages/[collection]/[id].astro +178 -122
  114. package/src/pages/[collection]/index.astro +24 -156
  115. package/src/pages/admin/api-explorer.astro +98 -0
  116. package/src/pages/admin/graphql-explorer.astro +40 -0
  117. package/src/pages/admin/graphql.astro +97 -0
  118. package/src/pages/admin/index.astro +286 -0
  119. package/src/pages/admin/keys.astro +8 -0
  120. package/src/pages/admin/rest-playground.astro +44 -0
  121. package/src/pages/admin/webhooks.astro +8 -0
  122. package/src/pages/api/[collection]/[id]/publish.ts +44 -0
  123. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  124. package/src/pages/api/[collection]/[id]/versions.ts +36 -0
  125. package/src/pages/api/[collection]/[id].ts +102 -159
  126. package/src/pages/api/[collection]/index.ts +151 -230
  127. package/src/pages/api/auth/[id].ts +48 -69
  128. package/src/pages/api/auth/audit-logs.ts +20 -43
  129. package/src/pages/api/auth/login.ts +159 -45
  130. package/src/pages/api/auth/logout.ts +50 -20
  131. package/src/pages/api/auth/refresh.ts +119 -0
  132. package/src/pages/api/auth/register.ts +110 -40
  133. package/src/pages/api/auth/users.ts +22 -97
  134. package/src/pages/api/collections.ts +59 -0
  135. package/src/pages/api/globals/[slug]/test.ts +172 -0
  136. package/src/pages/api/globals/[slug].ts +42 -0
  137. package/src/pages/api/graphql.ts +90 -0
  138. package/src/pages/api/health.ts +417 -40
  139. package/src/pages/api/keys/[id].ts +26 -0
  140. package/src/pages/api/keys/index.ts +75 -0
  141. package/src/pages/api/media/[id].ts +309 -0
  142. package/src/pages/api/media/folders.ts +609 -0
  143. package/src/pages/api/media/index.ts +146 -0
  144. package/src/pages/api/media/resize.ts +267 -0
  145. package/src/pages/api/search.ts +82 -0
  146. package/src/pages/api/slug-availability.ts +70 -0
  147. package/src/pages/api/storage-config.ts +20 -0
  148. package/src/pages/api/storage-status.ts +206 -0
  149. package/src/pages/api/upload.ts +334 -0
  150. package/src/pages/api/webhooks/index.ts +71 -0
  151. package/src/pages/audit/index.astro +2 -104
  152. package/src/pages/login.astro +82 -0
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +102 -0
  156. package/src/pages/roles/index.astro +21 -21
  157. package/src/pages/settings/[slug].astro +162 -0
  158. package/src/pages/settings/index.astro +9 -0
  159. package/src/pages/users/[id].astro +29 -21
  160. package/src/pages/users/index.astro +22 -17
  161. package/src/pages/users/new.astro +18 -17
  162. package/src/styles/main.css +553 -128
  163. package/src/components/layout/Sidebar.tsx +0 -497
  164. package/src/pages/index.astro +0 -225
@@ -0,0 +1,664 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { Modal } from "./ui/Modal";
3
+
4
+ interface AuditLog {
5
+ id: string;
6
+ timestamp: string | Date;
7
+ action: string;
8
+ userId?: string;
9
+ userEmail?: string;
10
+ role?: string;
11
+ resource: string;
12
+ resourceId?: string;
13
+ changes?: { field: string; old: unknown; new: unknown }[];
14
+ ipAddress?: string;
15
+ userAgent?: string;
16
+ success: boolean;
17
+ error?: string;
18
+ metadata?: Record<string, unknown>;
19
+ }
20
+
21
+ interface AuditLogsResponse {
22
+ docs: AuditLog[];
23
+ totalDocs: number;
24
+ page: number;
25
+ limit: number;
26
+ totalPages: number;
27
+ }
28
+
29
+ const ACTION_TYPES = [
30
+ "login",
31
+ "logout",
32
+ "login_failed",
33
+ "register",
34
+ "password_change",
35
+ "password_reset",
36
+ "user_create",
37
+ "user_update",
38
+ "user_delete",
39
+ "document_create",
40
+ "document_update",
41
+ "document_delete",
42
+ "settings_change",
43
+ "role_change",
44
+ ];
45
+
46
+ const ACTION_COLORS: Record<string, { bg: string; text: string }> = {
47
+ login: {
48
+ bg: "bg-green-500/10",
49
+ text: "text-green-500",
50
+ },
51
+ logout: {
52
+ bg: "bg-[var(--kyro-surface-accent)]",
53
+ text: "text-[var(--kyro-text-secondary)]",
54
+ },
55
+ login_failed: {
56
+ bg: "bg-red-500/10",
57
+ text: "text-red-500",
58
+ },
59
+ register: {
60
+ bg: "bg-blue-500/10",
61
+ text: "text-blue-500",
62
+ },
63
+ password_change: {
64
+ bg: "bg-yellow-500/10",
65
+ text: "text-yellow-500",
66
+ },
67
+ password_reset: {
68
+ bg: "bg-purple-500/10",
69
+ text: "text-purple-500",
70
+ },
71
+ user_create: {
72
+ bg: "bg-green-500/10",
73
+ text: "text-green-500",
74
+ },
75
+ user_update: {
76
+ bg: "bg-blue-500/10",
77
+ text: "text-blue-500",
78
+ },
79
+ user_delete: {
80
+ bg: "bg-red-500/10",
81
+ text: "text-red-500",
82
+ },
83
+ document_create: {
84
+ bg: "bg-green-500/10",
85
+ text: "text-green-500",
86
+ },
87
+ document_update: {
88
+ bg: "bg-blue-500/10",
89
+ text: "text-blue-500",
90
+ },
91
+ document_delete: {
92
+ bg: "bg-red-500/10",
93
+ text: "text-red-500",
94
+ },
95
+ settings_change: {
96
+ bg: "bg-orange-500/10",
97
+ text: "text-orange-500",
98
+ },
99
+ role_change: {
100
+ bg: "bg-indigo-500/10",
101
+ text: "text-indigo-500",
102
+ },
103
+ };
104
+
105
+ function formatAction(action: string): string {
106
+ return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
107
+ }
108
+
109
+ function formatTimestamp(ts: string | Date): string {
110
+ const d = new Date(ts);
111
+ return d.toLocaleString("en-US", {
112
+ weekday: "short",
113
+ month: "short",
114
+ day: "numeric",
115
+ year: "numeric",
116
+ hour: "2-digit",
117
+ minute: "2-digit",
118
+ second: "2-digit",
119
+ });
120
+ }
121
+
122
+ function getInitials(email: string): string {
123
+ return email.split("@")[0].slice(0, 2).toUpperCase();
124
+ }
125
+
126
+ function getAvatarColor(_email: string): string {
127
+ return "bg-[var(--kyro-surface-accent)]";
128
+ }
129
+
130
+ function getActionStyle(action: string) {
131
+ return (
132
+ ACTION_COLORS[action] || {
133
+ bg: "bg-[var(--kyro-surface-accent)]",
134
+ text: "text-[var(--kyro-text-secondary)]",
135
+ }
136
+ );
137
+ }
138
+
139
+ function MetadataRow({ label, value }: { label: string; value: unknown }) {
140
+ if (value === undefined || value === null || value === "") return null;
141
+ const display =
142
+ typeof value === "object" ? JSON.stringify(value) : String(value);
143
+ return (
144
+ <div className="flex items-start gap-3 py-2 px-4 border-b border-[var(--kyro-border)] last:border-0 bg-[var(--kyro-surface-accent)]">
145
+ <span className="text-[10px] font-bold uppercase tracking-wider text-[var(--kyro-text-muted)] w-24 shrink-0 pt-0.5">
146
+ {label}
147
+ </span>
148
+ <span className="text-xs font-mono text-[var(--kyro-text-primary)] break-all">
149
+ {display}
150
+ </span>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ export function AuditLogsPage() {
156
+ const [logs, setLogs] = useState<AuditLog[]>([]);
157
+ const [total, setTotal] = useState(0);
158
+ const [page, setPage] = useState(1);
159
+ const [limit] = useState(25);
160
+ const [totalPages, setTotalPages] = useState(1);
161
+ const [loading, setLoading] = useState(true);
162
+ const [search, setSearch] = useState("");
163
+ const [action, setAction] = useState("");
164
+ const [successFilter, setSuccessFilter] = useState<string>("");
165
+ const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
166
+
167
+ const fetchLogs = useCallback(
168
+ async (pageNum: number) => {
169
+ setLoading(true);
170
+ try {
171
+ const params = new URLSearchParams({
172
+ page: String(pageNum),
173
+ limit: String(limit),
174
+ });
175
+ if (search) params.set("userId", search);
176
+ if (action) params.set("action", action);
177
+ if (successFilter) params.set("success", successFilter);
178
+
179
+ const res = await fetch(`/api/auth/audit-logs?${params}`);
180
+ if (!res.ok) throw new Error("Failed to fetch");
181
+ const data: AuditLogsResponse = await res.json();
182
+ setLogs(data.docs);
183
+ setTotal(data.totalDocs);
184
+ setTotalPages(data.totalPages);
185
+ setPage(pageNum);
186
+ } catch (err) {
187
+ console.error("Failed to load audit logs:", err);
188
+ setLogs([]);
189
+ } finally {
190
+ setLoading(false);
191
+ }
192
+ },
193
+ [search, action, successFilter, limit],
194
+ );
195
+
196
+ useEffect(() => {
197
+ fetchLogs(1);
198
+ }, [search, action, successFilter]);
199
+
200
+ const stats = {
201
+ total,
202
+ successful: logs.filter((l) => l.success).length,
203
+ failed: logs.filter((l) => !l.success).length,
204
+ uniqueUsers: new Set(logs.map((l) => l.userEmail).filter(Boolean)).size,
205
+ };
206
+
207
+ return (
208
+ <div className="flex-1 overflow-y-auto p-8 pr-12 space-y-6">
209
+ {/* Header */}
210
+ <div className="surface-tile p-6 flex items-center justify-between gap-8">
211
+ <div>
212
+ <h1 className="text-3xl font-black tracking-tighter text-[var(--kyro-text-primary)]">
213
+ Audit Logs
214
+ </h1>
215
+ <p className="text-sm text-[var(--kyro-text-secondary)] mt-1 font-medium">
216
+ Security audit trail
217
+ <span className="ml-2 text-[var(--kyro-text-primary)] font-bold">
218
+ · {total.toLocaleString()} entries
219
+ </span>
220
+ </p>
221
+ </div>
222
+ <span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] border border-[var(--kyro-border)]">
223
+ <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
224
+ Live
225
+ </span>
226
+ </div>
227
+
228
+ {/* Filters */}
229
+ <div className="surface-tile p-4 flex flex-wrap items-center gap-3">
230
+ <div className="relative flex-1 min-w-48">
231
+ <svg
232
+ className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]"
233
+ fill="none"
234
+ stroke="currentColor"
235
+ viewBox="0 0 24 24"
236
+ >
237
+ <path
238
+ strokeLinecap="round"
239
+ strokeLinejoin="round"
240
+ strokeWidth="2"
241
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
242
+ />
243
+ </svg>
244
+ <input
245
+ type="text"
246
+ placeholder="Search by user email..."
247
+ value={search}
248
+ onChange={(e) => {
249
+ setSearch(e.target.value);
250
+ setPage(1);
251
+ }}
252
+ className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-xl text-sm font-medium text-[var(--kyro-text-primary)] placeholder-[var(--kyro-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-primary)] focus:border-transparent transition-all"
253
+ />
254
+ </div>
255
+
256
+ <select
257
+ value={action}
258
+ onChange={(e) => {
259
+ setAction(e.target.value);
260
+ setPage(1);
261
+ }}
262
+ className="px-4 py-2.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-primary)] cursor-pointer"
263
+ >
264
+ <option value="">All Actions</option>
265
+ {ACTION_TYPES.map((a) => (
266
+ <option key={a} value={a}>
267
+ {formatAction(a)}
268
+ </option>
269
+ ))}
270
+ </select>
271
+
272
+ <select
273
+ value={successFilter}
274
+ onChange={(e) => {
275
+ setSuccessFilter(e.target.value);
276
+ setPage(1);
277
+ }}
278
+ className="px-4 py-2.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-primary)] cursor-pointer"
279
+ >
280
+ <option value="">All Status</option>
281
+ <option value="true">Successful</option>
282
+ <option value="false">Failed</option>
283
+ </select>
284
+
285
+ {(search || action || successFilter) && (
286
+ <button type="button"
287
+ onClick={() => {
288
+ setSearch("");
289
+ setAction("");
290
+ setSuccessFilter("");
291
+ }}
292
+ className="px-4 py-2.5 text-sm font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
293
+ >
294
+ Clear
295
+ </button>
296
+ )}
297
+ </div>
298
+
299
+ {/* Stats Row */}
300
+ {!loading && total > 0 && (
301
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
302
+ {[
303
+ {
304
+ label: "Total Events",
305
+ value: total.toLocaleString(),
306
+ color: "text-[var(--kyro-text-primary)]",
307
+ },
308
+ {
309
+ label: "Successful",
310
+ value: stats.successful,
311
+ color: "text-green-500",
312
+ },
313
+ { label: "Failed", value: stats.failed, color: "text-red-500" },
314
+ {
315
+ label: "Unique Users",
316
+ value: stats.uniqueUsers,
317
+ color: "text-[var(--kyro-text-primary)]",
318
+ },
319
+ ].map(({ label, value, color }) => (
320
+ <div key={label} className="surface-tile p-4">
321
+ <div className={`text-2xl font-black ${color}`}>{value}</div>
322
+ <div className="text-[10px] font-bold text-[var(--kyro-text-secondary)] uppercase tracking-wider mt-1">
323
+ {label}
324
+ </div>
325
+ </div>
326
+ ))}
327
+ </div>
328
+ )}
329
+
330
+ {/* Table */}
331
+ <div className="surface-tile overflow-hidden">
332
+ {loading ? (
333
+ <div className="divide-y divide-[var(--kyro-border)]">
334
+ {Array.from({ length: 5 }).map((_, i) => (
335
+ <div
336
+ key={i}
337
+ className="h-16 animate-pulse bg-[var(--kyro-surface-accent)]"
338
+ />
339
+ ))}
340
+ </div>
341
+ ) : logs.length === 0 ? (
342
+ <div className="px-8 py-20 text-center">
343
+ <div className="flex flex-col items-center gap-4">
344
+ <div className="w-16 h-16 rounded-2xl bg-[var(--kyro-surface-accent)] flex items-center justify-center">
345
+ <svg
346
+ className="w-8 h-8 text-[var(--kyro-text-muted)]"
347
+ fill="none"
348
+ stroke="currentColor"
349
+ viewBox="0 0 24 24"
350
+ >
351
+ <path
352
+ strokeLinecap="round"
353
+ strokeLinejoin="round"
354
+ strokeWidth="1.5"
355
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
356
+ />
357
+ </svg>
358
+ </div>
359
+ <p className="font-bold text-[var(--kyro-text-primary)] text-base">
360
+ No audit logs found
361
+ </p>
362
+ <p className="text-sm text-[var(--kyro-text-secondary)]">
363
+ {search || action || successFilter
364
+ ? "Try adjusting your filters."
365
+ : "Logs will appear here as users interact with the system."}
366
+ </p>
367
+ </div>
368
+ </div>
369
+ ) : (
370
+ <table className="w-full text-left">
371
+ <thead>
372
+ <tr className="text-[var(--kyro-text-secondary)] font-bold text-[10px] uppercase tracking-[0.2em] border-b border-[var(--kyro-border)]">
373
+ <th className="px-6 py-5 w-8"></th>
374
+ <th className="px-6 py-5">Action</th>
375
+ <th className="px-6 py-5">User</th>
376
+ <th className="px-6 py-5">Resource</th>
377
+ <th className="px-6 py-5">Status</th>
378
+ <th className="px-6 py-5">Timestamp</th>
379
+ </tr>
380
+ </thead>
381
+ <tbody className="divide-y divide-[var(--kyro-border)]">
382
+ {logs.map((log) => {
383
+ const style = getActionStyle(log.action);
384
+ return (
385
+ <tr
386
+ key={log.id}
387
+ onClick={() => setSelectedLog(log)}
388
+ className="cursor-pointer hover:bg-[var(--kyro-surface-accent)] transition-colors"
389
+ >
390
+ <td className="px-6 py-4">
391
+ <span
392
+ className={`w-2 h-2 rounded-full block ${log.success ? "bg-green-500" : "bg-red-500"}`}
393
+ />
394
+ </td>
395
+ <td className="px-6 py-4">
396
+ <span
397
+ className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold ${style.bg} ${style.text}`}
398
+ >
399
+ {formatAction(log.action)}
400
+ </span>
401
+ </td>
402
+ <td className="px-6 py-4">
403
+ {log.userEmail ? (
404
+ <div className="flex items-center gap-2.5">
405
+ <div
406
+ className={`w-7 h-7 rounded-lg ${getAvatarColor(log.userEmail)} text-[var(--kyro-text-primary)] text-[10px] font-black flex items-center justify-center shrink-0`}
407
+ >
408
+ {getInitials(log.userEmail)}
409
+ </div>
410
+ <div>
411
+ <div className="text-sm font-bold text-[var(--kyro-text-primary)] leading-none">
412
+ {log.userEmail.split("@")[0]}
413
+ </div>
414
+ {log.role && (
415
+ <div className="text-[10px] font-bold text-[var(--kyro-text-muted)] mt-0.5 uppercase tracking-wider">
416
+ {log.role}
417
+ </div>
418
+ )}
419
+ </div>
420
+ </div>
421
+ ) : (
422
+ <span className="text-sm text-[var(--kyro-text-muted)]">
423
+ System
424
+ </span>
425
+ )}
426
+ </td>
427
+ <td className="px-6 py-4">
428
+ <div className="text-sm font-bold text-[var(--kyro-text-primary)]">
429
+ {log.resource}
430
+ </div>
431
+ {log.resourceId && (
432
+ <div className="text-[10px] font-mono text-[var(--kyro-text-muted)] mt-0.5">
433
+ {log.resourceId.slice(-8)}
434
+ </div>
435
+ )}
436
+ </td>
437
+ <td className="px-6 py-4">
438
+ {log.success ? (
439
+ <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold bg-green-500/10 text-green-500">
440
+ <svg
441
+ className="w-3 h-3"
442
+ fill="currentColor"
443
+ viewBox="0 0 20 20"
444
+ >
445
+ <path
446
+ fillRule="evenodd"
447
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
448
+ clipRule="evenodd"
449
+ />
450
+ </svg>
451
+ OK
452
+ </span>
453
+ ) : (
454
+ <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold bg-red-500/10 text-red-500">
455
+ <svg
456
+ className="w-3 h-3"
457
+ fill="currentColor"
458
+ viewBox="0 0 20 20"
459
+ >
460
+ <path
461
+ fillRule="evenodd"
462
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
463
+ clipRule="evenodd"
464
+ />
465
+ </svg>
466
+ Fail
467
+ </span>
468
+ )}
469
+ </td>
470
+ <td className="px-6 py-4">
471
+ <span className="text-xs text-[var(--kyro-text-secondary)] font-medium whitespace-nowrap">
472
+ {formatTimestamp(log.timestamp)}
473
+ </span>
474
+ </td>
475
+ </tr>
476
+ );
477
+ })}
478
+ </tbody>
479
+ </table>
480
+ )}
481
+ </div>
482
+
483
+ {/* Pagination */}
484
+ {totalPages > 1 && (
485
+ <div className="flex items-center justify-between">
486
+ <p className="text-sm text-[var(--kyro-text-secondary)] font-medium">
487
+ Page {page} of {totalPages}
488
+ <span className="ml-2 text-[var(--kyro-text-muted)]">
489
+ ({(page - 1) * limit + 1}–{Math.min(page * limit, total)} of{" "}
490
+ {total.toLocaleString()})
491
+ </span>
492
+ </p>
493
+ <div className="flex items-center gap-2">
494
+ <button type="button"
495
+ onClick={() => fetchLogs(page - 1)}
496
+ disabled={page <= 1}
497
+ className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
498
+ page <= 1
499
+ ? "opacity-30 pointer-events-none text-[var(--kyro-text-muted)]"
500
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] border border-[var(--kyro-border)]"
501
+ }`}
502
+ >
503
+ ← Prev
504
+ </button>
505
+ {Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
506
+ const p = i + 1;
507
+ return (
508
+ <button type="button"
509
+ key={p}
510
+ onClick={() => fetchLogs(p)}
511
+ className={`px-3.5 py-2 rounded-xl text-sm font-bold transition-all ${
512
+ p === page
513
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
514
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface)] border border-[var(--kyro-border)]"
515
+ }`}
516
+ >
517
+ {p}
518
+ </button>
519
+ );
520
+ })}
521
+ <button type="button"
522
+ onClick={() => fetchLogs(page + 1)}
523
+ disabled={page >= totalPages}
524
+ className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
525
+ page >= totalPages
526
+ ? "opacity-30 pointer-events-none text-[var(--kyro-text-muted)]"
527
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] border border-[var(--kyro-border)]"
528
+ }`}
529
+ >
530
+ Next →
531
+ </button>
532
+ </div>
533
+ </div>
534
+ )}
535
+
536
+ {/* Detail Modal */}
537
+ <Modal
538
+ open={!!selectedLog}
539
+ onClose={() => setSelectedLog(null)}
540
+ title="Audit Log Details"
541
+ size="lg"
542
+ >
543
+ {selectedLog && (
544
+ <div className="space-y-0">
545
+ {/* Status banner */}
546
+ <div
547
+ className={`-mx-6 -mt-4 px-6 py-4 mb-6 rounded-t-xl flex items-center gap-3 ${
548
+ selectedLog.success
549
+ ? "bg-green-500/5 border-b border-green-500/10"
550
+ : "bg-red-500/5 border-b border-red-500/10"
551
+ }`}
552
+ >
553
+ <span
554
+ className={`w-2.5 h-2.5 rounded-full shrink-0 ${selectedLog.success ? "bg-green-500" : "bg-red-500"}`}
555
+ />
556
+ <div>
557
+ <div
558
+ className={`text-sm font-black ${selectedLog.success ? "text-green-500" : "text-red-500"}`}
559
+ >
560
+ {selectedLog.success ? "Successful" : "Failed"}
561
+ </div>
562
+ <div className="text-xs font-medium text-[var(--kyro-text-secondary)]">
563
+ {formatAction(selectedLog.action)} on {selectedLog.resource}
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ {/* Fields */}
569
+ <MetadataRow label="Event ID" value={selectedLog.id} />
570
+ <MetadataRow
571
+ label="Timestamp"
572
+ value={formatTimestamp(selectedLog.timestamp)}
573
+ />
574
+ <MetadataRow label="User Email" value={selectedLog.userEmail} />
575
+ <MetadataRow label="User ID" value={selectedLog.userId} />
576
+ <MetadataRow label="Role" value={selectedLog.role} />
577
+ <MetadataRow label="Resource" value={selectedLog.resource} />
578
+ <MetadataRow label="Resource ID" value={selectedLog.resourceId} />
579
+ <MetadataRow label="IP Address" value={selectedLog.ipAddress} />
580
+
581
+ {/* Error message */}
582
+ {selectedLog.error && (
583
+ <div className="mt-4 -mx-6 px-6 py-4 bg-red-500/5 border-y border-red-500/10">
584
+ <div className="text-[10px] font-bold uppercase tracking-wider text-red-400 mb-2">
585
+ Error
586
+ </div>
587
+ <div className="text-sm font-mono text-red-300">
588
+ {selectedLog.error}
589
+ </div>
590
+ </div>
591
+ )}
592
+
593
+ {/* Changes */}
594
+ {selectedLog.changes && selectedLog.changes.length > 0 && (
595
+ <div className="mt-4">
596
+ <div className="text-[10px] font-bold uppercase tracking-wider text-[var(--kyro-text-muted)] mb-3">
597
+ Changes ({selectedLog.changes.length})
598
+ </div>
599
+ <div className="rounded-xl border border-[var(--kyro-border)] overflow-hidden">
600
+ {selectedLog.changes.map((change, i) => (
601
+ <div
602
+ key={i}
603
+ className="flex items-start gap-4 px-4 py-3 text-xs font-mono border-b border-[var(--kyro-border)] last:border-0 hover:bg-[var(--kyro-surface-accent)] transition-colors"
604
+ >
605
+ <span className="text-[10px] font-bold uppercase tracking-wider text-[var(--kyro-text-muted)] w-24 shrink-0 pt-0.5">
606
+ {change.field}
607
+ </span>
608
+ <span className="text-red-400 line-through flex-1 truncate">
609
+ {change.old === undefined || change.old === null
610
+ ? "(empty)"
611
+ : JSON.stringify(change.old)}
612
+ </span>
613
+ <span className="text-[var(--kyro-text-muted)] shrink-0">
614
+
615
+ </span>
616
+ <span className="text-green-400 flex-1 truncate">
617
+ {change.new === undefined || change.new === null
618
+ ? "(empty)"
619
+ : JSON.stringify(change.new)}
620
+ </span>
621
+ </div>
622
+ ))}
623
+ </div>
624
+ </div>
625
+ )}
626
+
627
+ {/* Metadata */}
628
+ {selectedLog.metadata &&
629
+ Object.keys(selectedLog.metadata).length > 0 && (
630
+ <div className="mt-4">
631
+ <div className="text-[10px] font-bold uppercase tracking-wider text-[var(--kyro-text-muted)] mb-3">
632
+ Metadata
633
+ </div>
634
+ <div className="border border-[var(--kyro-border)] overflow-hidden">
635
+ {Object.entries(selectedLog.metadata).map(
636
+ ([key, value], i) => (
637
+ <MetadataRow
638
+ key={key}
639
+ label={key.replace(/([A-Z])/g, " $1").trim()}
640
+ value={value as string}
641
+ />
642
+ ),
643
+ )}
644
+ </div>
645
+ </div>
646
+ )}
647
+
648
+ {/* User Agent */}
649
+ {selectedLog.userAgent && (
650
+ <div className="mt-4">
651
+ <div className="text-[10px] font-bold uppercase tracking-wider text-[var(--kyro-text-muted)] mb-2">
652
+ User Agent
653
+ </div>
654
+ <div className="text-xs font-mono text-[var(--kyro-text-secondary)] bg-[var(--kyro-bg-secondary)] rounded-lg px-3 py-2 border border-[var(--kyro-border)]">
655
+ {selectedLog.userAgent}
656
+ </div>
657
+ </div>
658
+ )}
659
+ </div>
660
+ )}
661
+ </Modal>
662
+ </div>
663
+ );
664
+ }