@kyro-cms/admin 0.1.2
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/.astro/content.d.ts +154 -0
- package/.astro/settings.json +5 -0
- package/.astro/types.d.ts +2 -0
- package/astro.config.mjs +28 -0
- package/bun.lock +1374 -0
- package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
- package/dist/client/_astro/client.DyczpTbx.js +9 -0
- package/dist/client/_astro/index.B02hbnpo.js +1 -0
- package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
- package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
- package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
- package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
- package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
- package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
- package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
- package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
- package/dist/server/chunks/index_CYofDU51.mjs +58 -0
- package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
- package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
- package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
- package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
- package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
- package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
- package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
- package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
- package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
- package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
- package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
- package/dist/server/entry.mjs +5 -0
- package/dist/server/virtual_astro_middleware.mjs +48 -0
- package/package.json +33 -0
- package/public/fonts/Serotiva-Black.woff2 +0 -0
- package/public/fonts/Serotiva-Bold.woff2 +0 -0
- package/public/fonts/Serotiva-Medium.woff2 +0 -0
- package/public/fonts/Serotiva-Regular.woff2 +0 -0
- package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/src/collections/auth/index.ts +155 -0
- package/src/components/ActionBar.tsx +215 -0
- package/src/components/Admin.tsx +214 -0
- package/src/components/AutoForm.tsx +1123 -0
- package/src/components/BulkActionsBar.tsx +80 -0
- package/src/components/CreateView.tsx +99 -0
- package/src/components/DetailView.tsx +329 -0
- package/src/components/Icons.tsx +23 -0
- package/src/components/ListView.tsx +192 -0
- package/src/components/StatusBadge.tsx +76 -0
- package/src/components/ThemeProvider.tsx +155 -0
- package/src/components/VersionHistoryPanel.tsx +205 -0
- package/src/components/fields/CheckboxField.tsx +37 -0
- package/src/components/fields/DateField.tsx +42 -0
- package/src/components/fields/NumberField.tsx +44 -0
- package/src/components/fields/RelationshipField.tsx +87 -0
- package/src/components/fields/SelectField.tsx +56 -0
- package/src/components/fields/TextField.tsx +49 -0
- package/src/components/index.ts +30 -0
- package/src/components/layout/Breadcrumbs.tsx +36 -0
- package/src/components/layout/Header.tsx +37 -0
- package/src/components/layout/Layout.tsx +25 -0
- package/src/components/layout/Sidebar.tsx +462 -0
- package/src/components/ui/Badge.tsx +14 -0
- package/src/components/ui/Button.tsx +41 -0
- package/src/components/ui/Dropdown.tsx +82 -0
- package/src/components/ui/Modal.tsx +135 -0
- package/src/components/ui/SlidePanel.tsx +73 -0
- package/src/components/ui/Spinner.tsx +24 -0
- package/src/components/ui/Toast.tsx +78 -0
- package/src/layouts/AdminLayout.astro +197 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/dataStore.ts +111 -0
- package/src/middleware.ts +48 -0
- package/src/pages/[collection]/[id].astro +176 -0
- package/src/pages/[collection]/index.astro +180 -0
- package/src/pages/api/[collection]/[id].ts +258 -0
- package/src/pages/api/[collection]/index.ts +289 -0
- package/src/pages/api/auth/[id].ts +142 -0
- package/src/pages/api/auth/audit-logs.ts +80 -0
- package/src/pages/api/auth/login.ts +101 -0
- package/src/pages/api/auth/logout.ts +48 -0
- package/src/pages/api/auth/me.ts +36 -0
- package/src/pages/api/auth/users.ts +150 -0
- package/src/pages/audit/index.astro +110 -0
- package/src/pages/index.astro +225 -0
- package/src/pages/roles/index.astro +114 -0
- package/src/pages/users/[id].astro +174 -0
- package/src/pages/users/index.astro +142 -0
- package/src/pages/users/new.astro +91 -0
- package/src/styles/main.css +1449 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "astro";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
|
|
4
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
5
|
+
|
|
6
|
+
const PUBLIC_PATHS = [
|
|
7
|
+
"/api/auth/login",
|
|
8
|
+
"/api/auth/logout",
|
|
9
|
+
"/api/auth/me",
|
|
10
|
+
"/api/health",
|
|
11
|
+
"/favicon.svg",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const PUBLIC_PREFIXES = ["/api/collections/", "/api/auth/"];
|
|
15
|
+
|
|
16
|
+
export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
|
|
17
|
+
const pathname = new URL(url).pathname;
|
|
18
|
+
|
|
19
|
+
if (PUBLIC_PATHS.includes(pathname)) {
|
|
20
|
+
return next();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const prefix of PUBLIC_PREFIXES) {
|
|
24
|
+
if (pathname.startsWith(prefix)) {
|
|
25
|
+
return next();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const authHeader = request.headers.get("authorization");
|
|
30
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
31
|
+
|
|
32
|
+
if (!token) {
|
|
33
|
+
return new Response(JSON.stringify({ error: "Authentication required" }), {
|
|
34
|
+
status: 401,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload;
|
|
41
|
+
return next();
|
|
42
|
+
} catch {
|
|
43
|
+
return new Response(JSON.stringify({ error: "Invalid or expired token" }), {
|
|
44
|
+
status: 401,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AdminLayout from '../../layouts/AdminLayout.astro';
|
|
3
|
+
import { collections } from '@/lib/config';
|
|
4
|
+
import { AutoForm } from '@/components/AutoForm';
|
|
5
|
+
|
|
6
|
+
const { collection, id } = Astro.params;
|
|
7
|
+
|
|
8
|
+
// Validate collection exists
|
|
9
|
+
if (!collection || !collections[collection]) {
|
|
10
|
+
return Astro.redirect('/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const config = collections[collection];
|
|
14
|
+
|
|
15
|
+
// Fetch document if editing
|
|
16
|
+
let doc: any = null;
|
|
17
|
+
if (id && id !== 'new') {
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(`${Astro.url.origin}/api/${collection}/${id}`);
|
|
20
|
+
if (response.ok) {
|
|
21
|
+
const result = await response.json();
|
|
22
|
+
doc = result.data || null;
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('Failed to fetch document:', error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isNew = !doc;
|
|
30
|
+
const title = isNew ? `Create ${config.singularLabel || config.label || collection}` : `Edit ${config.singularLabel || config.label || collection}`;
|
|
31
|
+
const description = config.admin?.description || `Manage ${(config.label || collection || '').toLowerCase()} documents`;
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<AdminLayout title={title}>
|
|
35
|
+
<div class="flex-1 overflow-y-auto p-8 space-y-6">
|
|
36
|
+
<!-- Header -->
|
|
37
|
+
<div class="surface-tile p-8 flex items-center justify-between">
|
|
38
|
+
<div class="flex items-center gap-4">
|
|
39
|
+
<a
|
|
40
|
+
href={`/${collection}`}
|
|
41
|
+
class="inline-flex items-center justify-center w-10 h-10 rounded-xl border border-gray-200 text-[#64748b] hover:text-[#0b1222] hover:bg-gray-50 transition-colors"
|
|
42
|
+
>
|
|
43
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
44
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"></path>
|
|
45
|
+
</svg>
|
|
46
|
+
</a>
|
|
47
|
+
<div>
|
|
48
|
+
<h1 class="text-2xl font-black tracking-tighter text-[#0b1222]">{title}</h1>
|
|
49
|
+
<p class="text-sm text-[#64748b] mt-0.5">{description}</p>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="flex items-center gap-3">
|
|
53
|
+
<a
|
|
54
|
+
href={`/${collection}`}
|
|
55
|
+
class="px-5 py-2.5 border border-gray-200 rounded-xl text-[#0b1222] font-bold text-sm hover:bg-gray-50 transition-colors"
|
|
56
|
+
>
|
|
57
|
+
Cancel
|
|
58
|
+
</a>
|
|
59
|
+
<button
|
|
60
|
+
type="submit"
|
|
61
|
+
form="doc-form"
|
|
62
|
+
id="btn-save"
|
|
63
|
+
class="px-6 py-2.5 bg-[#0b1222] text-white rounded-xl font-bold text-sm hover:bg-[#1a2332] transition-colors active:scale-95"
|
|
64
|
+
>
|
|
65
|
+
{isNew ? 'Create' : 'Save Changes'}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Form Card -->
|
|
71
|
+
<div class="surface-tile p-8">
|
|
72
|
+
<!-- Toast container -->
|
|
73
|
+
<div id="toast-container" class="hidden fixed bottom-6 right-6 z-50">
|
|
74
|
+
<div class="flex items-center gap-3 px-5 py-4 bg-[#0b1222] text-white rounded-xl shadow-2xl">
|
|
75
|
+
<span id="toast-message"></span>
|
|
76
|
+
<button onclick="document.getElementById('toast-container').classList.add('hidden')" class="text-white/50 hover:text-white ml-2">
|
|
77
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<form id="doc-form">
|
|
83
|
+
<AutoForm
|
|
84
|
+
client:load
|
|
85
|
+
config={config}
|
|
86
|
+
data={doc || {}}
|
|
87
|
+
collectionSlug={collection}
|
|
88
|
+
/>
|
|
89
|
+
</form>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Document Metadata (edit mode only) -->
|
|
93
|
+
{!isNew && doc && (
|
|
94
|
+
<div class="surface-tile p-8">
|
|
95
|
+
<h3 class="text-xs font-bold text-[#64748b] uppercase tracking-[0.2em] mb-4">Document Info</h3>
|
|
96
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
97
|
+
<div>
|
|
98
|
+
<p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">ID</p>
|
|
99
|
+
<p class="text-sm text-[#0b1222] font-mono">{doc.id?.slice(0, 12)}…</p>
|
|
100
|
+
</div>
|
|
101
|
+
{doc.createdAt && (
|
|
102
|
+
<div>
|
|
103
|
+
<p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">Created</p>
|
|
104
|
+
<p class="text-sm text-[#0b1222]">{new Date(doc.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
{doc.updatedAt && (
|
|
108
|
+
<div>
|
|
109
|
+
<p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">Updated</p>
|
|
110
|
+
<p class="text-sm text-[#0b1222]">{new Date(doc.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<script define:vars={{ collection, id, isNew }}>
|
|
119
|
+
function showToast(message, isError = false) {
|
|
120
|
+
const container = document.getElementById('toast-container');
|
|
121
|
+
const msg = document.getElementById('toast-message');
|
|
122
|
+
if (container && msg) {
|
|
123
|
+
msg.textContent = message;
|
|
124
|
+
container.classList.remove('hidden');
|
|
125
|
+
if (!isError) {
|
|
126
|
+
setTimeout(() => container.classList.add('hidden'), 3000);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
document.getElementById('doc-form')?.addEventListener('submit', async (e) => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
|
|
134
|
+
const btn = document.getElementById('btn-save');
|
|
135
|
+
const originalText = btn?.textContent || '';
|
|
136
|
+
if (btn) {
|
|
137
|
+
btn.textContent = 'Saving…';
|
|
138
|
+
btn.setAttribute('disabled', 'true');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const formData = new FormData(e.target);
|
|
142
|
+
const data = {};
|
|
143
|
+
formData.forEach((value, key) => {
|
|
144
|
+
data[key] = value;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const url = isNew ? `/api/${collection}` : `/api/${collection}/${id}`;
|
|
148
|
+
const method = isNew ? 'POST' : 'PATCH';
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(url, {
|
|
152
|
+
method,
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify(data),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (response.ok) {
|
|
158
|
+
showToast(isNew ? 'Document created successfully' : 'Changes saved');
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
window.location.href = `/${collection}`;
|
|
161
|
+
}, 800);
|
|
162
|
+
} else {
|
|
163
|
+
const error = await response.json();
|
|
164
|
+
showToast(error.error || 'An error occurred', true);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
showToast('Failed to save document', true);
|
|
168
|
+
} finally {
|
|
169
|
+
if (btn) {
|
|
170
|
+
btn.textContent = originalText;
|
|
171
|
+
btn.removeAttribute('disabled');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
</script>
|
|
176
|
+
</AdminLayout>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AdminLayout from '../../layouts/AdminLayout.astro';
|
|
3
|
+
import { collections } from '@/lib/config';
|
|
4
|
+
|
|
5
|
+
const { collection } = Astro.params;
|
|
6
|
+
|
|
7
|
+
// Validate collection exists
|
|
8
|
+
if (!collection || !collections[collection]) {
|
|
9
|
+
return Astro.redirect('/');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const config = collections[collection];
|
|
13
|
+
const visibleFields = config.fields.filter(f => f.name && !f.admin?.hidden && f.name !== 'id');
|
|
14
|
+
const displayFields = visibleFields.slice(0, 4);
|
|
15
|
+
|
|
16
|
+
// Fetch documents from API
|
|
17
|
+
let docs: any[] = [];
|
|
18
|
+
let totalDocs = 0;
|
|
19
|
+
const page = parseInt(Astro.url.searchParams.get('page') || '1');
|
|
20
|
+
const limit = parseInt(Astro.url.searchParams.get('limit') || '10');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${Astro.url.origin}/api/${collection}?page=${page}&limit=${limit}`);
|
|
24
|
+
if (response.ok) {
|
|
25
|
+
const data = await response.json();
|
|
26
|
+
docs = data.docs || [];
|
|
27
|
+
totalDocs = data.totalDocs || 0;
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('Failed to fetch documents:', error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const totalPages = Math.ceil(totalDocs / limit);
|
|
34
|
+
const collectionDescription = config.admin?.description || `Manage your ${config.label || collection} collection`;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<AdminLayout title={config.label || collection || 'Collection'}>
|
|
38
|
+
<div class="flex-1 overflow-y-auto p-8 space-y-6">
|
|
39
|
+
<!-- Header -->
|
|
40
|
+
<div class="surface-tile p-8 flex items-center justify-between">
|
|
41
|
+
<div>
|
|
42
|
+
<h1 class="text-3xl font-black tracking-tighter text-[#0b1222]">
|
|
43
|
+
{config.label || collection}
|
|
44
|
+
</h1>
|
|
45
|
+
<p class="text-sm text-[#64748b] mt-1 font-medium">
|
|
46
|
+
{collectionDescription}
|
|
47
|
+
{totalDocs > 0 && <span class="ml-2 text-[#0b1222] font-bold">· {totalDocs} documents</span>}
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
<a
|
|
51
|
+
href={`/${collection}/new`}
|
|
52
|
+
id="btn-create-new"
|
|
53
|
+
class="flex items-center gap-2 px-6 py-3 bg-[#0b1222] text-white rounded-xl font-bold transition-all hover:bg-[#1a2332] active:scale-95"
|
|
54
|
+
>
|
|
55
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
56
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"></path>
|
|
57
|
+
</svg>
|
|
58
|
+
Create {config.singularLabel || config.label || collection}
|
|
59
|
+
</a>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Data Table -->
|
|
63
|
+
<div class="surface-tile overflow-hidden p-0">
|
|
64
|
+
<table class="w-full text-left">
|
|
65
|
+
<thead>
|
|
66
|
+
<tr class="text-[#64748b] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-gray-100">
|
|
67
|
+
<th class="px-8 py-6 w-8">
|
|
68
|
+
<div class="w-5 h-5 rounded-md border-2 border-gray-200"></div>
|
|
69
|
+
</th>
|
|
70
|
+
{displayFields.map(field => (
|
|
71
|
+
<th class="px-6 py-6">{field.label || field.name}</th>
|
|
72
|
+
))}
|
|
73
|
+
{config.timestamps && (
|
|
74
|
+
<th class="px-6 py-6">Created</th>
|
|
75
|
+
)}
|
|
76
|
+
<th class="px-6 py-6 text-right">Actions</th>
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody class="divide-y divide-gray-50">
|
|
80
|
+
{docs.length === 0 ? (
|
|
81
|
+
<tr>
|
|
82
|
+
<td colspan="100%" class="px-8 py-16 text-center">
|
|
83
|
+
<div class="flex flex-col items-center gap-4">
|
|
84
|
+
<div class="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center">
|
|
85
|
+
<svg class="w-8 h-8 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
86
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
|
87
|
+
</svg>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<p class="font-bold text-[#0b1222] text-base">No documents yet</p>
|
|
91
|
+
<p class="text-sm text-[#64748b] mt-1">
|
|
92
|
+
Get started by creating your first {(config.singularLabel || config.label || collection || 'item').toLowerCase()}.
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<a
|
|
96
|
+
href={`/${collection}/new`}
|
|
97
|
+
class="mt-2 inline-flex items-center gap-2 px-5 py-2.5 bg-[#0b1222] text-white rounded-lg font-bold text-sm"
|
|
98
|
+
>
|
|
99
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
100
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"></path>
|
|
101
|
+
</svg>
|
|
102
|
+
Create {config.singularLabel || config.label || collection}
|
|
103
|
+
</a>
|
|
104
|
+
</div>
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
) : (
|
|
108
|
+
docs.map((doc) => (
|
|
109
|
+
<tr class="group hover:bg-gray-50/50 transition-colors cursor-pointer" onclick={`window.location='/${collection}/${doc.id}'`}>
|
|
110
|
+
<td class="px-8 py-5">
|
|
111
|
+
<div class="w-5 h-5 rounded-md border-2 border-gray-200 group-hover:border-[#0b1222] transition-colors"></div>
|
|
112
|
+
</td>
|
|
113
|
+
{displayFields.map((field, i) => (
|
|
114
|
+
<td class={`px-6 py-5 ${i === 0 ? 'font-bold text-[#0b1222]' : 'text-[#64748b]'}`}>
|
|
115
|
+
{field.type === 'select' && doc[field.name!]
|
|
116
|
+
? (field as any).options?.find((o: any) => o.value === doc[field.name!])?.label || doc[field.name!]
|
|
117
|
+
: String(doc[field.name!] || '—').slice(0, 60)}
|
|
118
|
+
</td>
|
|
119
|
+
))}
|
|
120
|
+
{config.timestamps && (
|
|
121
|
+
<td class="px-6 py-5 text-[#64748b] text-sm">
|
|
122
|
+
{doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
|
123
|
+
</td>
|
|
124
|
+
)}
|
|
125
|
+
<td class="px-6 py-5 text-right">
|
|
126
|
+
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
127
|
+
<a
|
|
128
|
+
href={`/${collection}/${doc.id}`}
|
|
129
|
+
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-[#64748b] hover:bg-gray-100 hover:text-[#0b1222] transition-colors"
|
|
130
|
+
onclick="event.stopPropagation()"
|
|
131
|
+
>
|
|
132
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
133
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
134
|
+
</svg>
|
|
135
|
+
</a>
|
|
136
|
+
<button
|
|
137
|
+
onclick={`event.stopPropagation(); if(confirm('Delete this document?')) { fetch('/api/${collection}/${doc.id}', { method: 'DELETE' }).then(() => location.reload()); }`}
|
|
138
|
+
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-[#64748b] hover:bg-gray-200 hover:text-[#0b1222] transition-colors"
|
|
139
|
+
>
|
|
140
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
141
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
142
|
+
</svg>
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</td>
|
|
146
|
+
</tr>
|
|
147
|
+
))
|
|
148
|
+
)}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- Pagination -->
|
|
154
|
+
{totalDocs > limit && (
|
|
155
|
+
<div class="flex items-center justify-between px-2">
|
|
156
|
+
<span class="text-sm text-[#64748b] font-medium">
|
|
157
|
+
Showing <span class="text-[#0b1222] font-bold">{(page - 1) * limit + 1}</span> to <span class="text-[#0b1222] font-bold">{Math.min(page * limit, totalDocs)}</span> of <span class="text-[#0b1222] font-bold">{totalDocs}</span>
|
|
158
|
+
</span>
|
|
159
|
+
<div class="flex gap-2">
|
|
160
|
+
{page > 1 && (
|
|
161
|
+
<a
|
|
162
|
+
href={`/${collection}?page=${page - 1}&limit=${limit}`}
|
|
163
|
+
class="px-4 py-2 border border-gray-200 rounded-lg text-sm font-bold text-[#0b1222] hover:bg-gray-50 transition-colors"
|
|
164
|
+
>
|
|
165
|
+
← Previous
|
|
166
|
+
</a>
|
|
167
|
+
)}
|
|
168
|
+
{page < totalPages && (
|
|
169
|
+
<a
|
|
170
|
+
href={`/${collection}?page=${page + 1}&limit=${limit}`}
|
|
171
|
+
class="px-4 py-2 bg-[#0b1222] text-white rounded-lg text-sm font-bold hover:bg-[#1a2332] transition-colors"
|
|
172
|
+
>
|
|
173
|
+
Next →
|
|
174
|
+
</a>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</AdminLayout>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { dataStore } from "@/lib/dataStore";
|
|
3
|
+
import { collections } from "@/lib/config";
|
|
4
|
+
|
|
5
|
+
dataStore.initialize(collections);
|
|
6
|
+
|
|
7
|
+
const AUTH_COLLECTIONS = ["users", "roles", "audit_logs"];
|
|
8
|
+
|
|
9
|
+
async function getAuthApi() {
|
|
10
|
+
const { RedisAuthAdapter } = await import("@kyro-cms/core");
|
|
11
|
+
return new RedisAuthAdapter({
|
|
12
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
13
|
+
tls: process.env.REDIS_TLS === "true",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
18
|
+
const collection = params.collection as string;
|
|
19
|
+
const id = params.id as string;
|
|
20
|
+
|
|
21
|
+
if (AUTH_COLLECTIONS.includes(collection)) {
|
|
22
|
+
try {
|
|
23
|
+
const adapter = await getAuthApi();
|
|
24
|
+
await adapter.connect();
|
|
25
|
+
|
|
26
|
+
if (collection === "users") {
|
|
27
|
+
const user = await adapter.findUserById(id);
|
|
28
|
+
await adapter.disconnect();
|
|
29
|
+
|
|
30
|
+
if (!user) {
|
|
31
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
32
|
+
status: 404,
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { passwordHash, ...safeUser } = user;
|
|
38
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (collection === "audit_logs") {
|
|
45
|
+
const logData = await (adapter as any).redis.hgetall(
|
|
46
|
+
`kyro:auth:audit:${id}`,
|
|
47
|
+
);
|
|
48
|
+
await adapter.disconnect();
|
|
49
|
+
|
|
50
|
+
if (!logData || !logData.id) {
|
|
51
|
+
return new Response(JSON.stringify({ error: "Log not found" }), {
|
|
52
|
+
status: 404,
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
data: {
|
|
60
|
+
id: logData.id,
|
|
61
|
+
action: logData.action,
|
|
62
|
+
userId: logData.userId,
|
|
63
|
+
userEmail: logData.userEmail,
|
|
64
|
+
role: logData.role,
|
|
65
|
+
resource: logData.resource,
|
|
66
|
+
ipAddress: logData.ipAddress,
|
|
67
|
+
success: logData.success === "true",
|
|
68
|
+
error: logData.error,
|
|
69
|
+
timestamp: logData.timestamp,
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await adapter.disconnect();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`Error fetching ${collection}:`, error);
|
|
79
|
+
return new Response(
|
|
80
|
+
JSON.stringify({ error: `Failed to fetch ${collection}` }),
|
|
81
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const doc = dataStore.findById(collection, id);
|
|
88
|
+
if (!doc) {
|
|
89
|
+
return new Response(JSON.stringify({ error: "Document not found" }), {
|
|
90
|
+
status: 404,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return new Response(JSON.stringify({ data: doc }), {
|
|
95
|
+
status: 200,
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return new Response(JSON.stringify({ error: "Failed to fetch document" }), {
|
|
100
|
+
status: 500,
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const PATCH: APIRoute = async ({ params, request }) => {
|
|
107
|
+
const collection = params.collection as string;
|
|
108
|
+
const id = params.id as string;
|
|
109
|
+
|
|
110
|
+
if (AUTH_COLLECTIONS.includes(collection)) {
|
|
111
|
+
try {
|
|
112
|
+
const adapter = await getAuthApi();
|
|
113
|
+
await adapter.connect();
|
|
114
|
+
|
|
115
|
+
const body = await request.json();
|
|
116
|
+
|
|
117
|
+
if (collection === "users") {
|
|
118
|
+
const existing = await adapter.findUserById(id);
|
|
119
|
+
if (!existing) {
|
|
120
|
+
await adapter.disconnect();
|
|
121
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
122
|
+
status: 404,
|
|
123
|
+
headers: { "Content-Type": "application/json" },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const updateData: Record<string, unknown> = {};
|
|
128
|
+
if (body.email !== undefined) updateData.email = body.email;
|
|
129
|
+
if (body.role !== undefined) updateData.role = body.role;
|
|
130
|
+
if (body.tenantId !== undefined) updateData.tenantId = body.tenantId;
|
|
131
|
+
if (body.locked !== undefined) updateData.locked = body.locked;
|
|
132
|
+
if (body.emailVerified !== undefined)
|
|
133
|
+
updateData.emailVerified = body.emailVerified;
|
|
134
|
+
|
|
135
|
+
if (body.password) {
|
|
136
|
+
updateData.passwordHash = await adapter.hashPassword(body.password);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const user = await adapter.updateUser(id, updateData);
|
|
140
|
+
await adapter.disconnect();
|
|
141
|
+
|
|
142
|
+
if (!user) {
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({ error: "Failed to update user" }),
|
|
145
|
+
{
|
|
146
|
+
status: 500,
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { passwordHash, ...safeUser } = user;
|
|
153
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
154
|
+
status: 200,
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await adapter.disconnect();
|
|
160
|
+
return new Response(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
error: `Collection ${collection} does not support PATCH`,
|
|
163
|
+
}),
|
|
164
|
+
{ status: 405, headers: { "Content-Type": "application/json" } },
|
|
165
|
+
);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error(`Error updating ${collection}:`, error);
|
|
168
|
+
return new Response(
|
|
169
|
+
JSON.stringify({ error: `Failed to update ${collection}` }),
|
|
170
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const body = await request.json();
|
|
177
|
+
const doc = dataStore.update(collection, id, body);
|
|
178
|
+
if (!doc) {
|
|
179
|
+
return new Response(JSON.stringify({ error: "Document not found" }), {
|
|
180
|
+
status: 404,
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return new Response(JSON.stringify({ data: doc }), {
|
|
185
|
+
status: 200,
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return new Response(
|
|
190
|
+
JSON.stringify({ error: "Failed to update document" }),
|
|
191
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const DELETE: APIRoute = async ({ params }) => {
|
|
197
|
+
const collection = params.collection as string;
|
|
198
|
+
const id = params.id as string;
|
|
199
|
+
|
|
200
|
+
if (AUTH_COLLECTIONS.includes(collection)) {
|
|
201
|
+
try {
|
|
202
|
+
const adapter = await getAuthApi();
|
|
203
|
+
await adapter.connect();
|
|
204
|
+
|
|
205
|
+
if (collection === "users") {
|
|
206
|
+
const existing = await adapter.findUserById(id);
|
|
207
|
+
if (!existing) {
|
|
208
|
+
await adapter.disconnect();
|
|
209
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
210
|
+
status: 404,
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await adapter.deleteUser(id);
|
|
216
|
+
await adapter.disconnect();
|
|
217
|
+
|
|
218
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
219
|
+
status: 200,
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await adapter.disconnect();
|
|
225
|
+
return new Response(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
error: `Collection ${collection} does not support DELETE`,
|
|
228
|
+
}),
|
|
229
|
+
{ status: 405, headers: { "Content-Type": "application/json" } },
|
|
230
|
+
);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error(`Error deleting ${collection}:`, error);
|
|
233
|
+
return new Response(
|
|
234
|
+
JSON.stringify({ error: `Failed to delete ${collection}` }),
|
|
235
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const success = dataStore.delete(collection, id);
|
|
242
|
+
if (!success) {
|
|
243
|
+
return new Response(JSON.stringify({ error: "Document not found" }), {
|
|
244
|
+
status: 404,
|
|
245
|
+
headers: { "Content-Type": "application/json" },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
249
|
+
status: 200,
|
|
250
|
+
headers: { "Content-Type": "application/json" },
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return new Response(
|
|
254
|
+
JSON.stringify({ error: "Failed to delete document" }),
|
|
255
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
};
|