@adminforge/core 0.3.1
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/.turbo/turbo-build.log +56 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/bin/adminforge.js +317 -0
- package/dist/auth-client.cjs +45 -0
- package/dist/auth-client.cjs.map +1 -0
- package/dist/auth-client.d.cts +17 -0
- package/dist/auth-client.d.ts +17 -0
- package/dist/auth-client.js +20 -0
- package/dist/auth-client.js.map +1 -0
- package/dist/auth.cjs +65 -0
- package/dist/auth.cjs.map +1 -0
- package/dist/auth.d.cts +21 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +36 -0
- package/dist/auth.js.map +1 -0
- package/dist/client-D0cjJVsn.d.ts +20 -0
- package/dist/client-sRnmZ-Y9.d.cts +20 -0
- package/dist/index-CyzxaE7n.d.cts +124 -0
- package/dist/index-CyzxaE7n.d.ts +124 -0
- package/dist/index.cjs +453 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +65 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +1 -0
- package/dist/next.cjs +839 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +84 -0
- package/dist/next.d.ts +84 -0
- package/dist/next.js +800 -0
- package/dist/next.js.map +1 -0
- package/dist/styles.css +763 -0
- package/dist/styles.css.map +1 -0
- package/dist/styles.d.cts +2 -0
- package/dist/styles.d.ts +2 -0
- package/dist/ui.cjs +2500 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +119 -0
- package/dist/ui.d.ts +119 -0
- package/dist/ui.js +2448 -0
- package/dist/ui.js.map +1 -0
- package/eslint.config.js +35 -0
- package/package.json +99 -0
- package/src/api/controller.ts +234 -0
- package/src/api/index.ts +4 -0
- package/src/api/next.ts +281 -0
- package/src/api/security/agent-auth.ts +134 -0
- package/src/auth/config.ts +20 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/middleware.ts +15 -0
- package/src/auth/provider.tsx +28 -0
- package/src/core/fields/index.ts +119 -0
- package/src/core/hooks/index.ts +60 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry/index.ts +22 -0
- package/src/core/schema/collection.ts +12 -0
- package/src/core/schema/config.ts +11 -0
- package/src/core/schema/normalize.ts +32 -0
- package/src/core/types/index.ts +114 -0
- package/src/db/client.ts +146 -0
- package/src/db/index.ts +3 -0
- package/src/db/schema-generator.ts +104 -0
- package/src/fields/index.ts +1 -0
- package/src/index.ts +4 -0
- package/src/next.ts +3 -0
- package/src/styles/adminforge.css +840 -0
- package/src/ui/AdminDashboard.tsx +176 -0
- package/src/ui/AdminForgeContext.tsx +64 -0
- package/src/ui/components/AdminLayout.tsx +107 -0
- package/src/ui/form-engine/FormEngine.tsx +250 -0
- package/src/ui/form-engine/ImageUpload.tsx +68 -0
- package/src/ui/form-engine/RelationInput.tsx +215 -0
- package/src/ui/form-engine/RichTextEditor.tsx +708 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/screens/AdminPage.tsx +162 -0
- package/src/ui/screens/AgentTokenPage.tsx +232 -0
- package/src/ui/screens/CollectionFormPage.tsx +135 -0
- package/src/ui/screens/CollectionListPage.tsx +170 -0
- package/src/ui/screens/CollectionSchemaPage.tsx +180 -0
- package/src/ui/screens/RoleDetailPage.tsx +147 -0
- package/src/ui/screens/RolesListPage.tsx +57 -0
- package/src/ui/table-engine/TableEngine.tsx +157 -0
- package/src/ui.ts +3 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +54 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AdminForgeConfig, CollectionDefinition, AccessConfig } from "../../core";
|
|
4
|
+
import { AdminLayout } from "../components/AdminLayout.js";
|
|
5
|
+
import { TableEngine } from "../table-engine/TableEngine.js";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import { useRouter } from "next/navigation";
|
|
9
|
+
import { useAdminSession } from "../../auth/provider.js";
|
|
10
|
+
|
|
11
|
+
interface CollectionListPageProps {
|
|
12
|
+
config: AdminForgeConfig;
|
|
13
|
+
collection: CollectionDefinition;
|
|
14
|
+
data: unknown[];
|
|
15
|
+
total: number;
|
|
16
|
+
page: number;
|
|
17
|
+
pageSize: number;
|
|
18
|
+
role?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasAccess(access: AccessConfig | undefined, operation: string, role?: string): boolean {
|
|
22
|
+
if (!access) return true;
|
|
23
|
+
const allowed = access[operation as keyof AccessConfig];
|
|
24
|
+
if (!allowed || !Array.isArray(allowed)) return true;
|
|
25
|
+
if (!role) return false;
|
|
26
|
+
return allowed.includes(role);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CollectionListPage({ config, collection, data, total, page, pageSize, role: propRole }: CollectionListPageProps) {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const session = useAdminSession();
|
|
32
|
+
const role = propRole || session?.role || session?.user?.role;
|
|
33
|
+
|
|
34
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
35
|
+
const canCreate = hasAccess(collection.access, "create", role);
|
|
36
|
+
|
|
37
|
+
const updateParams = useCallback((newParams: Record<string, string | number>) => {
|
|
38
|
+
const params = new URLSearchParams(window.location.search);
|
|
39
|
+
Object.entries(newParams).forEach(([key, value]) => {
|
|
40
|
+
if (value) params.set(key, String(value));
|
|
41
|
+
else params.delete(key);
|
|
42
|
+
});
|
|
43
|
+
router.push(`/admin/${collection.name}?${params.toString()}`);
|
|
44
|
+
}, [collection.name, router]);
|
|
45
|
+
|
|
46
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
updateParams({ search: searchQuery, page: 1 });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
52
|
+
const startEntry = (page - 1) * pageSize + 1;
|
|
53
|
+
const endEntry = Math.min(page * pageSize, total);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<AdminLayout config={config} currentPath={`/admin/${collection.name}`} role={role}>
|
|
57
|
+
<div className="adminforge-collection-page">
|
|
58
|
+
<div className="adminforge-page-header">
|
|
59
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
60
|
+
<h2 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>{collection.label}</h2>
|
|
61
|
+
<span className="adminforge-badge" style={{ background: '#f8fafc', color: '#64748b', borderColor: '#e2e8f0', fontSize: '11px', fontWeight: 600 }}>
|
|
62
|
+
{total} total records
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
{canCreate && (
|
|
66
|
+
<Link href={`/admin/${collection.name}/new`} className="adminforge-btn adminforge-btn-primary">
|
|
67
|
+
<span className="material-symbols-outlined" style={{ fontSize: '20px', marginRight: '8px' }}>add</span>
|
|
68
|
+
Create New
|
|
69
|
+
</Link>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', boxShadow: 'var(--shadow-sm)' }}>
|
|
74
|
+
<div style={{ padding: '20px', borderBottom: '1px solid var(--color-border)', background: '#fcfcfd' }}>
|
|
75
|
+
<form onSubmit={handleSearch} style={{ maxWidth: '400px', position: 'relative' }}>
|
|
76
|
+
<span className="material-symbols-outlined" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '20px' }}>search</span>
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
className="adminforge-search-input"
|
|
80
|
+
placeholder={`Search ${(collection.label || collection.name).toLowerCase()}...`}
|
|
81
|
+
value={searchQuery}
|
|
82
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
83
|
+
style={{ paddingLeft: '40px', width: '100%' }}
|
|
84
|
+
/>
|
|
85
|
+
</form>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<TableEngine collection={collection} data={data} basePath={`/admin/${collection.name}`} role={role} />
|
|
89
|
+
|
|
90
|
+
{/* Pagination Footer */}
|
|
91
|
+
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fcfcfd' }}>
|
|
92
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
93
|
+
<div style={{ fontSize: '14px', color: 'var(--color-text-secondary)' }}>
|
|
94
|
+
Showing <span style={{ fontWeight: 600, color: 'var(--color-text)' }}>{startEntry}</span> to <span style={{ fontWeight: 600, color: 'var(--color-text)' }}>{endEntry}</span> of <span style={{ fontWeight: 600, color: 'var(--color-text)' }}>{total}</span> results
|
|
95
|
+
</div>
|
|
96
|
+
<select
|
|
97
|
+
value={pageSize}
|
|
98
|
+
onChange={(e) => updateParams({ pageSize: e.target.value, page: 1 })}
|
|
99
|
+
style={{
|
|
100
|
+
padding: '4px 8px',
|
|
101
|
+
borderRadius: '6px',
|
|
102
|
+
border: '1px solid var(--color-border)',
|
|
103
|
+
fontSize: '12px',
|
|
104
|
+
background: 'white',
|
|
105
|
+
color: 'var(--color-text)',
|
|
106
|
+
cursor: 'pointer',
|
|
107
|
+
outline: 'none'
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{[10, 25, 50, 100].map(size => (
|
|
111
|
+
<option key={size} value={size}>{size} / page</option>
|
|
112
|
+
))}
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
<div style={{ display: 'flex', gap: '8px' }}>
|
|
116
|
+
<button
|
|
117
|
+
onClick={() => updateParams({ page: page - 1 })}
|
|
118
|
+
disabled={page <= 1}
|
|
119
|
+
className="adminforge-btn-icon"
|
|
120
|
+
style={{ border: '1px solid var(--color-border)', background: 'white' }}
|
|
121
|
+
>
|
|
122
|
+
<span className="material-symbols-outlined">chevron_left</span>
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
{[...Array(totalPages)].map((_, i) => {
|
|
126
|
+
const p = i + 1;
|
|
127
|
+
// Simple logic to show only current, first, last and surrounding pages if many
|
|
128
|
+
if (totalPages > 7 && p > 1 && p < totalPages && Math.abs(p - page) > 1) {
|
|
129
|
+
if (p === 2 || p === totalPages - 1) return <span key={p} style={{ padding: '0 4px' }}>...</span>;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={p}
|
|
135
|
+
onClick={() => updateParams({ page: p })}
|
|
136
|
+
style={{
|
|
137
|
+
width: '36px',
|
|
138
|
+
height: '36px',
|
|
139
|
+
display: 'flex',
|
|
140
|
+
alignItems: 'center',
|
|
141
|
+
justifyContent: 'center',
|
|
142
|
+
borderRadius: '6px',
|
|
143
|
+
border: '1px solid',
|
|
144
|
+
borderColor: p === page ? 'var(--color-primary)' : 'var(--color-border)',
|
|
145
|
+
background: p === page ? 'var(--color-primary)' : 'white',
|
|
146
|
+
color: p === page ? 'white' : 'var(--color-text)',
|
|
147
|
+
fontWeight: p === page ? 600 : 400,
|
|
148
|
+
cursor: 'pointer'
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{p}
|
|
152
|
+
</button>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => updateParams({ page: page + 1 })}
|
|
158
|
+
disabled={page >= totalPages}
|
|
159
|
+
className="adminforge-btn-icon"
|
|
160
|
+
style={{ border: '1px solid var(--color-border)', background: 'white' }}
|
|
161
|
+
>
|
|
162
|
+
<span className="material-symbols-outlined">chevron_right</span>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</AdminLayout>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AdminForgeConfig, CollectionDefinition, FieldDefinition } from "../../core";
|
|
4
|
+
import { AdminLayout } from "../components/AdminLayout.js";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
|
|
8
|
+
interface CollectionSchemaPageProps {
|
|
9
|
+
config: AdminForgeConfig;
|
|
10
|
+
collection: CollectionDefinition;
|
|
11
|
+
role?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatFullTimestamp(date: Date) {
|
|
15
|
+
if (isNaN(date.getTime())) return "Unknown";
|
|
16
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
17
|
+
month: 'short',
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
hour: 'numeric',
|
|
21
|
+
minute: '2-digit',
|
|
22
|
+
hour12: true
|
|
23
|
+
}).format(date);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function CollectionSchemaPage({ config, collection, role }: CollectionSchemaPageProps) {
|
|
27
|
+
const [lastActivity, setLastActivity] = useState<string>("Loading...");
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
fetch(`/api/${collection.name}?pageSize=1`)
|
|
31
|
+
.then(res => res.json())
|
|
32
|
+
.then(result => {
|
|
33
|
+
const item = result.data?.[0];
|
|
34
|
+
if (item) {
|
|
35
|
+
const dateStr = item.updatedAt || item.updated_at || item.createdAt || item.created_at;
|
|
36
|
+
if (dateStr) {
|
|
37
|
+
setLastActivity(formatFullTimestamp(new Date(dateStr)));
|
|
38
|
+
} else {
|
|
39
|
+
setLastActivity("No date info");
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
setLastActivity("No activity");
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.catch(() => setLastActivity("Unknown"));
|
|
46
|
+
}, [collection.name]);
|
|
47
|
+
|
|
48
|
+
const getFieldIcon = (type: string) => {
|
|
49
|
+
switch (type) {
|
|
50
|
+
case "text": return "text_fields";
|
|
51
|
+
case "slug": return "link";
|
|
52
|
+
case "richText": return "description";
|
|
53
|
+
case "boolean": return "toggle_on";
|
|
54
|
+
case "image": return "image";
|
|
55
|
+
case "relation": return "account_tree";
|
|
56
|
+
case "date": return "calendar_today";
|
|
57
|
+
default: return "label";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<AdminLayout config={config} currentPath={`/admin/${collection.name}`} role={role}>
|
|
63
|
+
<div className="adminforge-schema-page">
|
|
64
|
+
<div style={{ marginBottom: '32px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
|
65
|
+
<div>
|
|
66
|
+
<Link href="/admin" style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '14px', color: 'var(--color-text-secondary)', marginBottom: '16px', textDecoration: 'none' }}>
|
|
67
|
+
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>arrow_back</span>
|
|
68
|
+
Back to Collection Registry
|
|
69
|
+
</Link>
|
|
70
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
71
|
+
<h1 style={{ fontSize: '32px', fontWeight: 700 }}>{collection.label} Schema</h1>
|
|
72
|
+
<span className="adminforge-badge" style={{ background: '#f8fafc', color: '#64748b', borderColor: '#e2e8f0', fontSize: '11px', fontWeight: 600 }}>
|
|
73
|
+
defined in adminforge.ts
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '24px', alignItems: 'start' }}>
|
|
80
|
+
<div className="adminforge-card" style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
|
81
|
+
<div style={{ padding: '24px', borderBottom: '1px solid var(--color-border)' }}>
|
|
82
|
+
<h3 style={{ fontSize: '18px', fontWeight: 600 }}>Field Definitions</h3>
|
|
83
|
+
</div>
|
|
84
|
+
<table className="adminforge-table">
|
|
85
|
+
<thead>
|
|
86
|
+
<tr>
|
|
87
|
+
<th style={{ fontSize: '12px', color: 'var(--color-text-secondary)' }}>FIELD NAME</th>
|
|
88
|
+
<th style={{ fontSize: '12px', color: 'var(--color-text-secondary)' }}>TYPE</th>
|
|
89
|
+
<th style={{ fontSize: '12px', color: 'var(--color-text-secondary)' }}>PROPERTIES</th>
|
|
90
|
+
</tr>
|
|
91
|
+
</thead>
|
|
92
|
+
<tbody>
|
|
93
|
+
{Object.entries(collection.fields).map(([name, field]) => (
|
|
94
|
+
<tr key={name}>
|
|
95
|
+
<td style={{ fontWeight: 600 }}>{name}</td>
|
|
96
|
+
<td>
|
|
97
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
98
|
+
<span className="material-symbols-outlined" style={{ fontSize: '20px', color: 'var(--color-text-secondary)' }}>
|
|
99
|
+
{getFieldIcon(field.type)}
|
|
100
|
+
</span>
|
|
101
|
+
<span>{field.type}</span>
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
<td>
|
|
105
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
|
106
|
+
{field.meta?.required && <span className="adminforge-badge adminforge-badge-danger" style={{ fontSize: '11px' }}>required</span>}
|
|
107
|
+
{field.meta?.unique && <span className="adminforge-badge adminforge-badge-primary" style={{ fontSize: '11px' }}>unique</span>}
|
|
108
|
+
{!!field.ui.props?.from && <span className="adminforge-badge adminforge-badge-secondary" style={{ fontSize: '11px' }}>from: {String(field.ui.props.from)}</span>}
|
|
109
|
+
{!!field.ui.props?.to && (
|
|
110
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
111
|
+
<span style={{ fontSize: '12px' }}>to: <strong>{String(field.ui.props.to)}</strong></span>
|
|
112
|
+
<span className="adminforge-badge adminforge-badge-secondary" style={{ fontSize: '10px', alignSelf: 'flex-start' }}>{String(field.ui.props.relationType)}</span>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
{field.meta?.default !== undefined && <span style={{ fontSize: '12px', color: 'var(--color-text-secondary)' }}>default: <code>{JSON.stringify(field.meta.default)}</code></span>}
|
|
116
|
+
{!field.meta?.required && !field.meta?.unique && !field.ui.props?.from && !field.ui.props?.to && <span style={{ color: 'var(--color-text-secondary)' }}>-</span>}
|
|
117
|
+
</div>
|
|
118
|
+
</td>
|
|
119
|
+
</tr>
|
|
120
|
+
))}
|
|
121
|
+
</tbody>
|
|
122
|
+
</table>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="adminforge-card" style={{ padding: '24px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)' }}>
|
|
126
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
|
|
127
|
+
<span className="material-symbols-outlined" style={{ color: 'var(--color-text-secondary)' }}>info</span>
|
|
128
|
+
<h3 style={{ fontSize: '18px', fontWeight: 600 }}>Collection Meta</h3>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div style={{ marginBottom: '20px' }}>
|
|
132
|
+
<label style={{ fontSize: '12px', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>Collection Name</label>
|
|
133
|
+
<code style={{ background: '#f1f5f9', padding: '4px 8px', borderRadius: '4px', fontSize: '14px' }}>{collection.name}</code>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div style={{ marginBottom: '20px' }}>
|
|
137
|
+
<label style={{ fontSize: '12px', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>UI Label</label>
|
|
138
|
+
<span style={{ fontSize: '16px', fontWeight: 500 }}>{collection.label}</span>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div style={{ marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid var(--color-border)' }}>
|
|
142
|
+
<label style={{ fontSize: '12px', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>Last Activity</label>
|
|
143
|
+
<span style={{ fontSize: '14px', color: 'var(--color-text-secondary)' }}>{lastActivity}</span>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div>
|
|
147
|
+
<label style={{ fontSize: '12px', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', marginBottom: '16px', display: 'block' }}>Access Rules</label>
|
|
148
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
149
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
150
|
+
<span style={{ fontSize: '14px' }}>Create</span>
|
|
151
|
+
<div style={{ display: 'flex', gap: '4px' }}>
|
|
152
|
+
{(collection.access?.create || ['admin']).map(role => (
|
|
153
|
+
<span key={role} className="adminforge-badge adminforge-badge-secondary" style={{ fontSize: '11px' }}>{role}</span>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
158
|
+
<span style={{ fontSize: '14px' }}>Update</span>
|
|
159
|
+
<div style={{ display: 'flex', gap: '4px' }}>
|
|
160
|
+
{(collection.access?.update || ['admin']).map(role => (
|
|
161
|
+
<span key={role} className="adminforge-badge adminforge-badge-secondary" style={{ fontSize: '11px' }}>{role}</span>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
166
|
+
<span style={{ fontSize: '14px' }}>Delete</span>
|
|
167
|
+
<div style={{ display: 'flex', gap: '4px' }}>
|
|
168
|
+
{(collection.access?.delete || ['admin']).map(role => (
|
|
169
|
+
<span key={role} className="adminforge-badge adminforge-badge-danger" style={{ fontSize: '11px', background: '#fee2e2', color: '#b91c1c' }}>{role}</span>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</AdminLayout>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AdminForgeConfig, CollectionDefinition, AccessConfig } from "../../core";
|
|
4
|
+
import { AdminLayout } from "../components/AdminLayout.js";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
|
|
7
|
+
interface RoleDetailPageProps {
|
|
8
|
+
config: AdminForgeConfig;
|
|
9
|
+
roleId: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasAccess(access: AccessConfig | undefined, operation: string, roleName: string): boolean {
|
|
14
|
+
if (!access) return true;
|
|
15
|
+
const allowed = access[operation as keyof AccessConfig];
|
|
16
|
+
if (!allowed || !Array.isArray(allowed)) return true;
|
|
17
|
+
return allowed.includes(roleName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RoleDetailPage({ config, roleId, role }: RoleDetailPageProps) {
|
|
21
|
+
const roleDef = config.auth?.roles?.[roleId];
|
|
22
|
+
if (!roleDef) {
|
|
23
|
+
return (
|
|
24
|
+
<AdminLayout config={config} currentPath="/admin/roles" role={role}>
|
|
25
|
+
<div className="adminforge-collection-page">
|
|
26
|
+
<div className="adminforge-page-header">
|
|
27
|
+
<h2 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>Role not found</h2>
|
|
28
|
+
</div>
|
|
29
|
+
<Link href="/admin/roles" className="adminforge-btn adminforge-btn-secondary">Back to Roles</Link>
|
|
30
|
+
</div>
|
|
31
|
+
</AdminLayout>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<AdminLayout config={config} currentPath="/admin/roles" role={role}>
|
|
37
|
+
<div className="adminforge-collection-page">
|
|
38
|
+
<div className="adminforge-page-header">
|
|
39
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
40
|
+
<Link href="/admin/roles" className="adminforge-btn-icon" style={{ border: '1px solid var(--color-border)', background: 'white' }}>
|
|
41
|
+
<span className="material-symbols-outlined">arrow_back</span>
|
|
42
|
+
</Link>
|
|
43
|
+
<h2 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>Role: {roleDef.label || roleId}</h2>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="adminforge-badge" style={{ background: '#f8fafc', color: '#64748b', borderColor: '#e2e8f0', fontSize: '11px', fontWeight: 600 }}>
|
|
46
|
+
Read-only Config
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div style={{ marginBottom: '32px' }}>
|
|
51
|
+
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', color: 'var(--color-text)' }}>Permissions Matrix</h3>
|
|
52
|
+
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', boxShadow: 'var(--shadow-sm)' }}>
|
|
53
|
+
<table className="adminforge-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
54
|
+
<thead>
|
|
55
|
+
<tr style={{ background: '#fcfcfd', borderBottom: '1px solid var(--color-border)' }}>
|
|
56
|
+
<th style={{ textAlign: 'left', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Collection</th>
|
|
57
|
+
<th style={{ textAlign: 'center', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Read</th>
|
|
58
|
+
<th style={{ textAlign: 'center', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Create</th>
|
|
59
|
+
<th style={{ textAlign: 'center', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Update</th>
|
|
60
|
+
<th style={{ textAlign: 'center', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Delete</th>
|
|
61
|
+
</tr>
|
|
62
|
+
</thead>
|
|
63
|
+
<tbody>
|
|
64
|
+
{config.collections.map((collection) => {
|
|
65
|
+
const canRead = hasAccess(collection.access, 'read', roleId);
|
|
66
|
+
const canCreate = hasAccess(collection.access, 'create', roleId);
|
|
67
|
+
const canUpdate = hasAccess(collection.access, 'update', roleId);
|
|
68
|
+
const canDelete = hasAccess(collection.access, 'delete', roleId);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<tr key={collection.name} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
|
72
|
+
<td style={{ padding: '16px 24px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
|
73
|
+
{collection.label || collection.name}
|
|
74
|
+
</td>
|
|
75
|
+
<td style={{ padding: '16px 24px', textAlign: 'center' }}>
|
|
76
|
+
<PermissionStatus allowed={canRead} />
|
|
77
|
+
</td>
|
|
78
|
+
<td style={{ padding: '16px 24px', textAlign: 'center' }}>
|
|
79
|
+
<PermissionStatus allowed={canCreate} />
|
|
80
|
+
</td>
|
|
81
|
+
<td style={{ padding: '16px 24px', textAlign: 'center' }}>
|
|
82
|
+
<PermissionStatus allowed={canUpdate} />
|
|
83
|
+
</td>
|
|
84
|
+
<td style={{ padding: '16px 24px', textAlign: 'center' }}>
|
|
85
|
+
<PermissionStatus allowed={canDelete} />
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div>
|
|
96
|
+
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', color: 'var(--color-text)' }}>Field-Level Overrides</h3>
|
|
97
|
+
{config.collections.map((collection) => {
|
|
98
|
+
const fieldsWithAccess = Object.entries(collection.fields).filter(([_, field]) => field.access);
|
|
99
|
+
if (fieldsWithAccess.length === 0) return null;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div key={collection.name} style={{ marginBottom: '24px', padding: '20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', background: 'white' }}>
|
|
103
|
+
<h4 style={{ fontSize: '14px', fontWeight: 600, marginBottom: '12px', color: 'var(--color-text-secondary)' }}>{collection.label || collection.name}</h4>
|
|
104
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '12px' }}>
|
|
105
|
+
{fieldsWithAccess.map(([name, field]) => {
|
|
106
|
+
const canRead = hasAccess(field.access, 'read', roleId);
|
|
107
|
+
const canCreate = hasAccess(field.access, 'create', roleId);
|
|
108
|
+
const canUpdate = hasAccess(field.access, 'update', roleId);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div key={name} style={{ padding: '12px', border: '1px solid #f1f5f9', borderRadius: '8px', background: '#f8fafc' }}>
|
|
112
|
+
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px' }}><code>{name}</code></div>
|
|
113
|
+
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
|
|
114
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: canRead ? '#10b981' : '#ef4444' }}>
|
|
115
|
+
<span className="material-symbols-outlined" style={{ fontSize: '14px' }}>{canRead ? 'check_circle' : 'cancel'}</span> read
|
|
116
|
+
</span>
|
|
117
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: canCreate ? '#10b981' : '#ef4444' }}>
|
|
118
|
+
<span className="material-symbols-outlined" style={{ fontSize: '14px' }}>{canCreate ? 'check_circle' : 'cancel'}</span> create
|
|
119
|
+
</span>
|
|
120
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: canUpdate ? '#10b981' : '#ef4444' }}>
|
|
121
|
+
<span className="material-symbols-outlined" style={{ fontSize: '14px' }}>{canUpdate ? 'check_circle' : 'cancel'}</span> update
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</AdminLayout>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function PermissionStatus({ allowed }: { allowed: boolean }) {
|
|
138
|
+
return (
|
|
139
|
+
<span className="material-symbols-outlined" style={{
|
|
140
|
+
color: allowed ? '#10b981' : '#ef4444',
|
|
141
|
+
fontSize: '20px',
|
|
142
|
+
fontWeight: 'bold'
|
|
143
|
+
}}>
|
|
144
|
+
{allowed ? 'check' : 'close'}
|
|
145
|
+
</span>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AdminForgeConfig } from "../../core";
|
|
4
|
+
import { AdminLayout } from "../components/AdminLayout.js";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
|
|
7
|
+
interface RolesListPageProps {
|
|
8
|
+
config: AdminForgeConfig;
|
|
9
|
+
role?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function RolesListPage({ config, role }: RolesListPageProps) {
|
|
13
|
+
const roles = Object.entries(config.auth?.roles || {});
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<AdminLayout config={config} currentPath="/admin/roles" role={role}>
|
|
17
|
+
<div className="adminforge-collection-page">
|
|
18
|
+
<div className="adminforge-page-header">
|
|
19
|
+
<h2 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>Roles</h2>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', boxShadow: 'var(--shadow-sm)' }}>
|
|
23
|
+
<table className="adminforge-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
24
|
+
<thead>
|
|
25
|
+
<tr style={{ background: '#fcfcfd', borderBottom: '1px solid var(--color-border)' }}>
|
|
26
|
+
<th style={{ textAlign: 'left', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Role Name</th>
|
|
27
|
+
<th style={{ textAlign: 'left', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Label</th>
|
|
28
|
+
<th style={{ textAlign: 'right', padding: '16px 24px', fontSize: '13px', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Actions</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
{roles.map(([key, roleDef]) => (
|
|
33
|
+
<tr key={key} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
|
34
|
+
<td style={{ padding: '16px 24px', fontSize: '14px', color: 'var(--color-text)', fontWeight: 500 }}>
|
|
35
|
+
<code>{key}</code>
|
|
36
|
+
</td>
|
|
37
|
+
<td style={{ padding: '16px 24px', fontSize: '14px', color: 'var(--color-text-secondary)' }}>
|
|
38
|
+
{roleDef.label}
|
|
39
|
+
</td>
|
|
40
|
+
<td style={{ padding: '16px 24px', textAlign: 'right' }}>
|
|
41
|
+
<Link
|
|
42
|
+
href={`/admin/roles/${key}`}
|
|
43
|
+
className="adminforge-btn adminforge-btn-secondary"
|
|
44
|
+
style={{ padding: '6px 12px', fontSize: '13px' }}
|
|
45
|
+
>
|
|
46
|
+
View Permissions
|
|
47
|
+
</Link>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
))}
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</AdminLayout>
|
|
56
|
+
);
|
|
57
|
+
}
|