@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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +56 -0
  2. package/CHANGELOG.md +32 -0
  3. package/LICENSE +21 -0
  4. package/bin/adminforge.js +317 -0
  5. package/dist/auth-client.cjs +45 -0
  6. package/dist/auth-client.cjs.map +1 -0
  7. package/dist/auth-client.d.cts +17 -0
  8. package/dist/auth-client.d.ts +17 -0
  9. package/dist/auth-client.js +20 -0
  10. package/dist/auth-client.js.map +1 -0
  11. package/dist/auth.cjs +65 -0
  12. package/dist/auth.cjs.map +1 -0
  13. package/dist/auth.d.cts +21 -0
  14. package/dist/auth.d.ts +21 -0
  15. package/dist/auth.js +36 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/client-D0cjJVsn.d.ts +20 -0
  18. package/dist/client-sRnmZ-Y9.d.cts +20 -0
  19. package/dist/index-CyzxaE7n.d.cts +124 -0
  20. package/dist/index-CyzxaE7n.d.ts +124 -0
  21. package/dist/index.cjs +453 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/index.d.cts +65 -0
  24. package/dist/index.d.ts +65 -0
  25. package/dist/index.js +410 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/next.cjs +839 -0
  28. package/dist/next.cjs.map +1 -0
  29. package/dist/next.d.cts +84 -0
  30. package/dist/next.d.ts +84 -0
  31. package/dist/next.js +800 -0
  32. package/dist/next.js.map +1 -0
  33. package/dist/styles.css +763 -0
  34. package/dist/styles.css.map +1 -0
  35. package/dist/styles.d.cts +2 -0
  36. package/dist/styles.d.ts +2 -0
  37. package/dist/ui.cjs +2500 -0
  38. package/dist/ui.cjs.map +1 -0
  39. package/dist/ui.d.cts +119 -0
  40. package/dist/ui.d.ts +119 -0
  41. package/dist/ui.js +2448 -0
  42. package/dist/ui.js.map +1 -0
  43. package/eslint.config.js +35 -0
  44. package/package.json +99 -0
  45. package/src/api/controller.ts +234 -0
  46. package/src/api/index.ts +4 -0
  47. package/src/api/next.ts +281 -0
  48. package/src/api/security/agent-auth.ts +134 -0
  49. package/src/auth/config.ts +20 -0
  50. package/src/auth/index.ts +3 -0
  51. package/src/auth/middleware.ts +15 -0
  52. package/src/auth/provider.tsx +28 -0
  53. package/src/core/fields/index.ts +119 -0
  54. package/src/core/hooks/index.ts +60 -0
  55. package/src/core/index.ts +43 -0
  56. package/src/core/registry/index.ts +22 -0
  57. package/src/core/schema/collection.ts +12 -0
  58. package/src/core/schema/config.ts +11 -0
  59. package/src/core/schema/normalize.ts +32 -0
  60. package/src/core/types/index.ts +114 -0
  61. package/src/db/client.ts +146 -0
  62. package/src/db/index.ts +3 -0
  63. package/src/db/schema-generator.ts +104 -0
  64. package/src/fields/index.ts +1 -0
  65. package/src/index.ts +4 -0
  66. package/src/next.ts +3 -0
  67. package/src/styles/adminforge.css +840 -0
  68. package/src/ui/AdminDashboard.tsx +176 -0
  69. package/src/ui/AdminForgeContext.tsx +64 -0
  70. package/src/ui/components/AdminLayout.tsx +107 -0
  71. package/src/ui/form-engine/FormEngine.tsx +250 -0
  72. package/src/ui/form-engine/ImageUpload.tsx +68 -0
  73. package/src/ui/form-engine/RelationInput.tsx +215 -0
  74. package/src/ui/form-engine/RichTextEditor.tsx +708 -0
  75. package/src/ui/index.ts +18 -0
  76. package/src/ui/screens/AdminPage.tsx +162 -0
  77. package/src/ui/screens/AgentTokenPage.tsx +232 -0
  78. package/src/ui/screens/CollectionFormPage.tsx +135 -0
  79. package/src/ui/screens/CollectionListPage.tsx +170 -0
  80. package/src/ui/screens/CollectionSchemaPage.tsx +180 -0
  81. package/src/ui/screens/RoleDetailPage.tsx +147 -0
  82. package/src/ui/screens/RolesListPage.tsx +57 -0
  83. package/src/ui/table-engine/TableEngine.tsx +157 -0
  84. package/src/ui.ts +3 -0
  85. package/tsconfig.json +10 -0
  86. 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
+ }