@kyro-cms/admin 0.1.6 → 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.
- package/README.md +149 -51
- package/package.json +53 -6
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +136 -27
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +1417 -661
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +3 -3
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +199 -57
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +786 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +191 -53
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +149 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +97 -0
- package/src/components/blocks/ArrayBlock.tsx +75 -0
- package/src/components/blocks/BlockEditModal.MARKER +12 -0
- package/src/components/blocks/BlockEditModal.tsx +774 -0
- package/src/components/blocks/ButtonBlock.tsx +165 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +66 -0
- package/src/components/blocks/ColumnsBlock.tsx +151 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +64 -0
- package/src/components/blocks/HeadingBlock.tsx +81 -0
- package/src/components/blocks/HeroBlock.tsx +157 -0
- package/src/components/blocks/ImageBlock.tsx +83 -0
- package/src/components/blocks/LinkBlock.tsx +71 -0
- package/src/components/blocks/ListBlock.tsx +39 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +279 -0
- package/src/components/blocks/VStackBlock.tsx +75 -0
- package/src/components/blocks/VideoBlock.tsx +45 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/CheckboxField.tsx +15 -9
- package/src/components/fields/CodeField.tsx +234 -0
- package/src/components/fields/DateField.tsx +38 -11
- package/src/components/fields/EditorClient.tsx +271 -0
- package/src/components/fields/FileField.tsx +390 -0
- package/src/components/fields/HybridContentField.tsx +109 -0
- package/src/components/fields/ImageField.tsx +429 -0
- package/src/components/fields/JSONField.tsx +361 -0
- package/src/components/fields/MarkdownField.tsx +282 -0
- package/src/components/fields/NumberField.tsx +42 -12
- package/src/components/fields/PortableTextField.tsx +143 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipField.tsx +231 -59
- package/src/components/fields/SelectField.tsx +25 -15
- package/src/components/fields/TextField.tsx +45 -14
- package/src/components/fields/extensions/blockComponents.tsx +237 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +13 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +2 -2
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +22 -6
- package/src/lib/dataStore.ts +132 -74
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +44 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +36 -0
- package/src/pages/api/[collection]/[id].ts +102 -159
- package/src/pages/api/[collection]/index.ts +151 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +553 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -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
|
+
}
|