@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,150 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { RedisAuthAdapter } from "@kyro-cms/core";
|
|
3
|
+
import { AuditLogger } from "@kyro-cms/core";
|
|
4
|
+
import { createAuditContext } from "@kyro-cms/core";
|
|
5
|
+
import bcrypt from "bcryptjs";
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
7
|
+
|
|
8
|
+
const redisAdapter = new RedisAuthAdapter({
|
|
9
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const auditLogger = new AuditLogger(redisAdapter as any);
|
|
13
|
+
|
|
14
|
+
async function ensureConnection() {
|
|
15
|
+
try {
|
|
16
|
+
await redisAdapter.connect();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
// Connection might already be established
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const GET: APIRoute = async ({ url, request }) => {
|
|
23
|
+
await ensureConnection();
|
|
24
|
+
|
|
25
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
26
|
+
const limit = parseInt(url.searchParams.get("limit") || "25");
|
|
27
|
+
const search = url.searchParams.get("search") || "";
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const pattern = search ? `*${search.toLowerCase()}*` : "*";
|
|
31
|
+
|
|
32
|
+
let cursor = "0";
|
|
33
|
+
const users: any[] = [];
|
|
34
|
+
const seenIds = new Set<string>();
|
|
35
|
+
|
|
36
|
+
do {
|
|
37
|
+
const [nextCursor, keys] = await (redisAdapter as any).redis.scan(
|
|
38
|
+
cursor,
|
|
39
|
+
"MATCH",
|
|
40
|
+
"kyro:auth:users:email:*",
|
|
41
|
+
"COUNT",
|
|
42
|
+
100,
|
|
43
|
+
);
|
|
44
|
+
cursor = nextCursor;
|
|
45
|
+
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const userId = await (redisAdapter as any).redis.get(key);
|
|
48
|
+
if (userId && !seenIds.has(userId)) {
|
|
49
|
+
seenIds.add(userId);
|
|
50
|
+
const user = await redisAdapter.findUserById(userId);
|
|
51
|
+
if (user) {
|
|
52
|
+
const { passwordHash, ...safeUser } = user;
|
|
53
|
+
users.push(safeUser);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} while (cursor !== "0");
|
|
58
|
+
|
|
59
|
+
const totalDocs = users.length;
|
|
60
|
+
const startIndex = (page - 1) * limit;
|
|
61
|
+
const paginatedUsers = users.slice(startIndex, startIndex + limit);
|
|
62
|
+
|
|
63
|
+
return new Response(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
docs: paginatedUsers,
|
|
66
|
+
totalDocs,
|
|
67
|
+
page,
|
|
68
|
+
limit,
|
|
69
|
+
totalPages: Math.ceil(totalDocs / limit),
|
|
70
|
+
}),
|
|
71
|
+
{
|
|
72
|
+
status: 200,
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error("Error fetching users:", error);
|
|
78
|
+
return new Response(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
error: "Failed to fetch users",
|
|
81
|
+
docs: [],
|
|
82
|
+
totalDocs: 0,
|
|
83
|
+
}),
|
|
84
|
+
{
|
|
85
|
+
status: 200,
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
93
|
+
await ensureConnection();
|
|
94
|
+
|
|
95
|
+
const { ipAddress, userAgent } = createAuditContext(request as any);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const body = await request.json();
|
|
99
|
+
const { email, password, role, tenantId } = body;
|
|
100
|
+
|
|
101
|
+
if (!email || !password) {
|
|
102
|
+
return new Response(
|
|
103
|
+
JSON.stringify({ error: "Email and password are required" }),
|
|
104
|
+
{
|
|
105
|
+
status: 400,
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const existing = await redisAdapter.findUserByEmail(email);
|
|
112
|
+
if (existing) {
|
|
113
|
+
return new Response(JSON.stringify({ error: "Email already exists" }), {
|
|
114
|
+
status: 400,
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
120
|
+
const user = await redisAdapter.createUser({
|
|
121
|
+
email,
|
|
122
|
+
passwordHash,
|
|
123
|
+
role: role || "customer",
|
|
124
|
+
tenantId,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await auditLogger.log({
|
|
128
|
+
action: "user_create",
|
|
129
|
+
userId: user.id,
|
|
130
|
+
userEmail: user.email,
|
|
131
|
+
role: user.role,
|
|
132
|
+
resource: "users",
|
|
133
|
+
ipAddress,
|
|
134
|
+
userAgent,
|
|
135
|
+
success: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { passwordHash: _, ...safeUser } = user;
|
|
139
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
140
|
+
status: 201,
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error("Error creating user:", error);
|
|
145
|
+
return new Response(JSON.stringify({ error: "Failed to create user" }), {
|
|
146
|
+
status: 500,
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AdminLayout from '../../layouts/AdminLayout.astro';
|
|
3
|
+
|
|
4
|
+
let auditLogs: any[] = [];
|
|
5
|
+
let totalLogs = 0;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetch(`${Astro.url.origin}/api/audit_logs?page=1&limit=50`);
|
|
9
|
+
if (response.ok) {
|
|
10
|
+
const data = await response.json();
|
|
11
|
+
auditLogs = data.docs || [];
|
|
12
|
+
totalLogs = data.totalDocs || 0;
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error('Failed to fetch audit logs:', error);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const actionColors: Record<string, string> = {
|
|
19
|
+
login: 'bg-green-50 text-green-600',
|
|
20
|
+
logout: 'bg-gray-50 text-gray-600',
|
|
21
|
+
login_failed: 'bg-red-50 text-red-600',
|
|
22
|
+
register: 'bg-blue-50 text-blue-600',
|
|
23
|
+
password_change: 'bg-yellow-50 text-yellow-600',
|
|
24
|
+
password_reset: 'bg-purple-50 text-purple-600',
|
|
25
|
+
user_create: 'bg-green-50 text-green-600',
|
|
26
|
+
user_update: 'bg-blue-50 text-blue-600',
|
|
27
|
+
user_delete: 'bg-red-50 text-red-600',
|
|
28
|
+
user_lockout: 'bg-orange-50 text-orange-600',
|
|
29
|
+
};
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<AdminLayout title="Audit Logs">
|
|
33
|
+
<div class="flex-1 overflow-y-auto p-8 pr-12 space-y-8">
|
|
34
|
+
<!-- Header -->
|
|
35
|
+
<div class="surface-tile p-6">
|
|
36
|
+
<h1 class="text-3xl font-black tracking-tighter text-[#0b1222]">Audit Logs</h1>
|
|
37
|
+
<p class="text-sm text-[#64748b] mt-1 font-medium">
|
|
38
|
+
Security audit trail
|
|
39
|
+
<span class="ml-2 text-[#0b1222] font-bold">· {totalLogs} entries</span>
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Logs Table -->
|
|
44
|
+
<div class="surface-tile overflow-hidden">
|
|
45
|
+
{auditLogs.length === 0 ? (
|
|
46
|
+
<div class="px-8 py-16 text-center">
|
|
47
|
+
<div class="flex flex-col items-center gap-4">
|
|
48
|
+
<div class="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center">
|
|
49
|
+
<svg class="w-8 h-8 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
50
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
51
|
+
</svg>
|
|
52
|
+
</div>
|
|
53
|
+
<p class="font-bold text-[#0b1222] text-base">No audit logs yet</p>
|
|
54
|
+
<p class="text-sm text-[#64748b]">Logs will appear here as users interact with the system.</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
) : (
|
|
58
|
+
<table class="w-full text-left">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr class="text-[#64748b] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-gray-100">
|
|
61
|
+
<th class="px-8 py-6">Action</th>
|
|
62
|
+
<th class="px-6 py-6">User</th>
|
|
63
|
+
<th class="px-6 py-6">Resource</th>
|
|
64
|
+
<th class="px-6 py-6">IP Address</th>
|
|
65
|
+
<th class="px-6 py-6">Status</th>
|
|
66
|
+
<th class="px-6 py-6">Timestamp</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody class="divide-y divide-gray-50">
|
|
70
|
+
{auditLogs.map((log) => (
|
|
71
|
+
<tr class="group hover:bg-gray-50/50 transition-colors">
|
|
72
|
+
<td class="px-8 py-4">
|
|
73
|
+
<span class={`inline-flex items-center px-3 py-1 rounded-lg text-xs font-bold ${actionColors[log.action] || 'bg-gray-50 text-gray-600'}`}>
|
|
74
|
+
{log.action.replace(/_/g, ' ')}
|
|
75
|
+
</span>
|
|
76
|
+
</td>
|
|
77
|
+
<td class="px-6 py-4">
|
|
78
|
+
<div class="text-sm font-bold text-[#0b1222]">{log.userEmail || '—'}</div>
|
|
79
|
+
{log.role && <div class="text-xs text-[#64748b]">{log.role}</div>}
|
|
80
|
+
</td>
|
|
81
|
+
<td class="px-6 py-4 text-sm text-[#64748b]">{log.resource}</td>
|
|
82
|
+
<td class="px-6 py-4 text-sm text-[#64748b] font-mono">{log.ipAddress || '—'}</td>
|
|
83
|
+
<td class="px-6 py-4">
|
|
84
|
+
{log.success ? (
|
|
85
|
+
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold bg-green-50 text-green-600">
|
|
86
|
+
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
87
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
88
|
+
</svg>
|
|
89
|
+
Success
|
|
90
|
+
</span>
|
|
91
|
+
) : (
|
|
92
|
+
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold bg-red-50 text-red-600">
|
|
93
|
+
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
94
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
95
|
+
</svg>
|
|
96
|
+
Failed
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</td>
|
|
100
|
+
<td class="px-6 py-4 text-sm text-[#64748b]">
|
|
101
|
+
{log.timestamp ? new Date(log.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
))}
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</AdminLayout>
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AdminLayout from '../layouts/AdminLayout.astro';
|
|
3
|
+
import { collections } from "../lib/config";
|
|
4
|
+
|
|
5
|
+
const authCollections = ['users', 'roles', 'audit_logs'];
|
|
6
|
+
const authItems = authCollections.map(slug => ({
|
|
7
|
+
slug,
|
|
8
|
+
label: collections[slug]?.label || slug,
|
|
9
|
+
icon: slug === 'users' ? 'users' : slug === 'roles' ? 'shield' : 'file-text'
|
|
10
|
+
}));
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<AdminLayout title="Dashboard">
|
|
14
|
+
<div class="flex-1 overflow-y-auto p-8 pr-12 space-y-8">
|
|
15
|
+
<!-- Header -->
|
|
16
|
+
<div class="surface-tile p-6 flex items-center justify-between gap-8">
|
|
17
|
+
<div class="relative flex-1 max-w-2xl">
|
|
18
|
+
<div class="absolute inset-y-0 left-6 flex items-center pointer-events-none text-[#64748b]">
|
|
19
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
20
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
21
|
+
</svg>
|
|
22
|
+
</div>
|
|
23
|
+
<input type="text" placeholder="Search anything..." class="w-full bg-gray-50/50 border border-transparent rounded-2xl py-4 pl-16 pr-8 text-lg font-medium focus:outline-none focus:bg-white focus:border-gray-100 transition-all shadow-inner" />
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex p-1.5 bg-gray-50/50 rounded-2xl">
|
|
26
|
+
<button class="flex items-center gap-3 px-8 py-3 bg-[#0b1222] text-white rounded-xl font-bold shadow-lg transition-all active:scale-95">
|
|
27
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
28
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
|
|
29
|
+
</svg>
|
|
30
|
+
<span>Card</span>
|
|
31
|
+
</button>
|
|
32
|
+
<button class="flex items-center gap-3 px-8 py-3 text-[#64748b] rounded-xl font-bold hover:bg-white/50 transition-all">
|
|
33
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
34
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
35
|
+
</svg>
|
|
36
|
+
<span>List</span>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Auth Management Section -->
|
|
42
|
+
<div class="surface-tile overflow-hidden">
|
|
43
|
+
<div class="flex items-center justify-between p-12 border-b border-gray-50/50">
|
|
44
|
+
<div class="flex-1">
|
|
45
|
+
<h2 class="text-5xl font-black tracking-tighter text-[#0b1222]">
|
|
46
|
+
Authentication
|
|
47
|
+
</h2>
|
|
48
|
+
<p class="text-[#64748b] font-bold mt-4 uppercase tracking-[0.2em] text-sm">
|
|
49
|
+
Manage users, roles, and security
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<a href="/users/new" 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">
|
|
53
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
54
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"></path>
|
|
55
|
+
</svg>
|
|
56
|
+
Add User
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
|
|
61
|
+
{authItems.map((item) => (
|
|
62
|
+
<a href={`/${item.slug}`} class="group p-6 bg-gray-50/30 rounded-2xl hover:bg-gray-50/50 transition-all border border-transparent hover:border-gray-100/50">
|
|
63
|
+
<div class="flex items-center gap-4 mb-4">
|
|
64
|
+
<div class={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
|
65
|
+
item.slug === 'users' ? 'bg-blue-50 text-blue-500' :
|
|
66
|
+
item.slug === 'roles' ? 'bg-purple-50 text-purple-500' :
|
|
67
|
+
'bg-green-50 text-green-500'
|
|
68
|
+
}`}>
|
|
69
|
+
{item.icon === 'users' && (
|
|
70
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
71
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
|
72
|
+
</svg>
|
|
73
|
+
)}
|
|
74
|
+
{item.icon === 'shield' && (
|
|
75
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
76
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
|
77
|
+
</svg>
|
|
78
|
+
)}
|
|
79
|
+
{item.icon === 'file-text' && (
|
|
80
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
81
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
82
|
+
</svg>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
<div>
|
|
86
|
+
<h3 class="text-xl font-black text-[#0b1222] tracking-tighter">{item.label}</h3>
|
|
87
|
+
<p class="text-sm text-[#64748b] font-medium mt-1">Manage {item.slug.replace('_', ' ')}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="flex items-center gap-2 text-[#64748b] font-bold text-sm group-hover:text-[#0b1222] transition-colors">
|
|
91
|
+
<span>View all</span>
|
|
92
|
+
<svg class="w-4 h-4 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
93
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
94
|
+
</svg>
|
|
95
|
+
</div>
|
|
96
|
+
</a>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- Security Quick Actions -->
|
|
102
|
+
<div class="surface-tile overflow-hidden">
|
|
103
|
+
<div class="p-8 border-b border-gray-50/50">
|
|
104
|
+
<h2 class="text-3xl font-black tracking-tighter text-[#0b1222]">
|
|
105
|
+
Security & Monitoring
|
|
106
|
+
</h2>
|
|
107
|
+
<p class="text-[#64748b] font-bold mt-2 text-sm">
|
|
108
|
+
Rate limiting, audit logs, and account lockout settings
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 p-6">
|
|
112
|
+
<a href="/audit_logs" class="p-5 bg-gray-50/30 rounded-xl hover:bg-gray-50/50 transition-all border border-transparent hover:border-gray-100/50 group">
|
|
113
|
+
<div class="w-10 h-10 rounded-lg bg-orange-50 text-orange-500 flex items-center justify-center mb-3">
|
|
114
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
115
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
116
|
+
</svg>
|
|
117
|
+
</div>
|
|
118
|
+
<h4 class="font-bold text-[#0b1222] mb-1">Audit Logs</h4>
|
|
119
|
+
<p class="text-xs text-[#64748b]">View last 30 days</p>
|
|
120
|
+
</a>
|
|
121
|
+
|
|
122
|
+
<a href="/users?locked=true" class="p-5 bg-gray-50/30 rounded-xl hover:bg-gray-50/50 transition-all border border-transparent hover:border-gray-100/50 group">
|
|
123
|
+
<div class="w-10 h-10 rounded-lg bg-red-50 text-red-500 flex items-center justify-center mb-3">
|
|
124
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
125
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
|
126
|
+
</svg>
|
|
127
|
+
</div>
|
|
128
|
+
<h4 class="font-bold text-[#0b1222] mb-1">Locked Accounts</h4>
|
|
129
|
+
<p class="text-xs text-[#64748b]">Manage lockouts</p>
|
|
130
|
+
</a>
|
|
131
|
+
|
|
132
|
+
<a href="/roles" class="p-5 bg-gray-50/30 rounded-xl hover:bg-gray-50/50 transition-all border border-transparent hover:border-gray-100/50 group">
|
|
133
|
+
<div class="w-10 h-10 rounded-lg bg-indigo-50 text-indigo-500 flex items-center justify-center mb-3">
|
|
134
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
135
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
|
136
|
+
</svg>
|
|
137
|
+
</div>
|
|
138
|
+
<h4 class="font-bold text-[#0b1222] mb-1">Permissions</h4>
|
|
139
|
+
<p class="text-xs text-[#64748b]">RBAC settings</p>
|
|
140
|
+
</a>
|
|
141
|
+
|
|
142
|
+
<a href="/api/health" target="_blank" class="p-5 bg-gray-50/30 rounded-xl hover:bg-gray-50/50 transition-all border border-transparent hover:border-gray-100/50 group">
|
|
143
|
+
<div class="w-10 h-10 rounded-lg bg-green-50 text-green-500 flex items-center justify-center mb-3">
|
|
144
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
145
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
146
|
+
</svg>
|
|
147
|
+
</div>
|
|
148
|
+
<h4 class="font-bold text-[#0b1222] mb-1">API Health</h4>
|
|
149
|
+
<p class="text-xs text-[#64748b]">System status</p>
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Environment Info -->
|
|
155
|
+
<div class="surface-tile p-6">
|
|
156
|
+
<h3 class="text-xl font-black text-[#0b1222] tracking-tighter mb-4">Configuration</h3>
|
|
157
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
158
|
+
<div class="bg-gray-50/50 rounded-xl p-4">
|
|
159
|
+
<div class="text-xs text-[#64748b] font-bold uppercase tracking-wider mb-1">Database</div>
|
|
160
|
+
<div class="text-sm font-bold text-[#0b1222]">PostgreSQL</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="bg-gray-50/50 rounded-xl p-4">
|
|
163
|
+
<div class="text-xs text-[#64748b] font-bold uppercase tracking-wider mb-1">Cache/Sessions</div>
|
|
164
|
+
<div class="text-sm font-bold text-[#0b1222]">Redis</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="bg-gray-50/50 rounded-xl p-4">
|
|
167
|
+
<div class="text-xs text-[#64748b] font-bold uppercase tracking-wider mb-1">Auth Method</div>
|
|
168
|
+
<div class="text-sm font-bold text-[#0b1222]">JWT + Redis</div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="bg-gray-50/50 rounded-xl p-4">
|
|
171
|
+
<div class="text-xs text-[#64748b] font-bold uppercase tracking-wider mb-1">Email</div>
|
|
172
|
+
<div class="text-sm font-bold text-[#0b1222]">Nodemailer</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
<p class="text-xs text-[#64748b] mt-4 font-medium">
|
|
176
|
+
Configure via <code class="bg-gray-100 px-2 py-0.5 rounded text-[#0b1222]">.env</code> file
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- Quick Links -->
|
|
181
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
182
|
+
<a href="/api/health" target="_blank" class="surface-tile group p-6 hover:shadow-2xl transition-all hover:translate-y-[-4px]">
|
|
183
|
+
<div class="flex items-center gap-4">
|
|
184
|
+
<div class="w-14 h-14 rounded-2xl bg-green-50 flex items-center justify-center text-green-500 group-hover:scale-110 transition-transform">
|
|
185
|
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
186
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
187
|
+
</svg>
|
|
188
|
+
</div>
|
|
189
|
+
<div>
|
|
190
|
+
<h4 class="text-lg font-bold text-[#0b1222]">API Health</h4>
|
|
191
|
+
<p class="text-sm text-[#64748b]">Check system status</p>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</a>
|
|
195
|
+
|
|
196
|
+
<a href="/graphql" target="_blank" class="surface-tile group p-6 hover:shadow-2xl transition-all hover:translate-y-[-4px]">
|
|
197
|
+
<div class="flex items-center gap-4">
|
|
198
|
+
<div class="w-14 h-14 rounded-2xl bg-pink-50 flex items-center justify-center text-pink-500 group-hover:scale-110 transition-transform">
|
|
199
|
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
200
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
201
|
+
</svg>
|
|
202
|
+
</div>
|
|
203
|
+
<div>
|
|
204
|
+
<h4 class="text-lg font-bold text-[#0b1222]">GraphQL</h4>
|
|
205
|
+
<p class="text-sm text-[#64748b]">API Playground</p>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</a>
|
|
209
|
+
|
|
210
|
+
<a href="/api/collections" target="_blank" class="surface-tile group p-6 hover:shadow-2xl transition-all hover:translate-y-[-4px]">
|
|
211
|
+
<div class="flex items-center gap-4">
|
|
212
|
+
<div class="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-500 group-hover:scale-110 transition-transform">
|
|
213
|
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
214
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"></path>
|
|
215
|
+
</svg>
|
|
216
|
+
</div>
|
|
217
|
+
<div>
|
|
218
|
+
<h4 class="text-lg font-bold text-[#0b1222]">REST API</h4>
|
|
219
|
+
<p class="text-sm text-[#64748b]">Collections endpoint</p>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</a>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</AdminLayout>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AdminLayout from '../../layouts/AdminLayout.astro';
|
|
3
|
+
|
|
4
|
+
const defaultRoles = [
|
|
5
|
+
{ name: 'super_admin', level: 100, inherits: ['admin'], description: 'Full system access across all tenants', color: 'bg-red-50 text-red-600 border-red-200' },
|
|
6
|
+
{ name: 'admin', level: 90, inherits: ['editor'], description: 'Full tenant access with all content permissions', color: 'bg-purple-50 text-purple-600 border-purple-200' },
|
|
7
|
+
{ name: 'editor', level: 70, inherits: ['author'], description: 'Edit and publish all content', color: 'bg-blue-50 text-blue-600 border-blue-200' },
|
|
8
|
+
{ name: 'author', level: 50, inherits: ['customer'], description: 'Create and edit own content', color: 'bg-green-50 text-green-600 border-green-200' },
|
|
9
|
+
{ name: 'customer', level: 30, inherits: [], description: 'Access own data and make purchases', color: 'bg-gray-50 text-gray-600 border-gray-200' },
|
|
10
|
+
{ name: 'guest', level: 10, inherits: [], description: 'Public read-only access', color: 'bg-yellow-50 text-yellow-600 border-yellow-200' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const permissions: Record<string, string[]> = {
|
|
14
|
+
super_admin: ['*'],
|
|
15
|
+
admin: ['users:*', 'roles:*', 'content:*', 'settings:*', 'audit:read'],
|
|
16
|
+
editor: ['content:*', 'media:*', 'comments:*'],
|
|
17
|
+
author: ['content:create', 'content:read', 'content:update:own', 'media:*'],
|
|
18
|
+
customer: ['content:read', 'orders:*', 'profile:*'],
|
|
19
|
+
guest: ['content:read'],
|
|
20
|
+
};
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<AdminLayout title="Roles">
|
|
24
|
+
<div class="flex-1 overflow-y-auto p-8 pr-12 space-y-8">
|
|
25
|
+
<!-- Header -->
|
|
26
|
+
<div class="surface-tile p-6">
|
|
27
|
+
<h1 class="text-3xl font-black tracking-tighter text-[#0b1222]">Roles & Permissions</h1>
|
|
28
|
+
<p class="text-sm text-[#64748b] mt-1 font-medium">
|
|
29
|
+
Role-based access control with hierarchical permissions
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- Role Hierarchy -->
|
|
34
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
35
|
+
{defaultRoles.map((role) => (
|
|
36
|
+
<div class={`surface-tile p-6 border-2 ${role.color.split(' ').slice(2).join(' ')} rounded-2xl`}>
|
|
37
|
+
<div class="flex items-center justify-between mb-4">
|
|
38
|
+
<h3 class="text-xl font-black text-[#0b1222] tracking-tighter">{role.name}</h3>
|
|
39
|
+
<span class={`px-3 py-1 rounded-lg text-xs font-bold ${role.color.split(' ').slice(0, 2).join(' ')}`}>
|
|
40
|
+
Level {role.level}
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
<p class="text-sm text-[#64748b] mb-4">{role.description}</p>
|
|
44
|
+
{role.inherits.length > 0 && (
|
|
45
|
+
<div class="mb-4">
|
|
46
|
+
<span class="text-xs font-bold text-[#64748b] uppercase tracking-wider">Inherits:</span>
|
|
47
|
+
<div class="flex gap-2 mt-2">
|
|
48
|
+
{role.inherits.map((parent) => (
|
|
49
|
+
<span class="px-2 py-1 bg-gray-100 rounded text-xs font-bold text-[#64748b]">{parent}</span>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
<div>
|
|
55
|
+
<span class="text-xs font-bold text-[#64748b] uppercase tracking-wider">Permissions:</span>
|
|
56
|
+
<div class="flex flex-wrap gap-1.5 mt-2">
|
|
57
|
+
{(permissions[role.name] || []).map((perm) => (
|
|
58
|
+
<span class="px-2 py-0.5 bg-gray-50 rounded text-[10px] font-bold text-[#0b1222]">{perm}</span>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Permission Matrix -->
|
|
67
|
+
<div class="surface-tile overflow-hidden">
|
|
68
|
+
<div class="p-6 border-b border-gray-100">
|
|
69
|
+
<h2 class="text-xl font-black text-[#0b1222] tracking-tighter">Permission Matrix</h2>
|
|
70
|
+
</div>
|
|
71
|
+
<table class="w-full text-left">
|
|
72
|
+
<thead>
|
|
73
|
+
<tr class="text-[#64748b] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-gray-100">
|
|
74
|
+
<th class="px-6 py-4">Resource</th>
|
|
75
|
+
{defaultRoles.map((role) => (
|
|
76
|
+
<th class="px-4 py-4 text-center">{role.name}</th>
|
|
77
|
+
))}
|
|
78
|
+
</tr>
|
|
79
|
+
</thead>
|
|
80
|
+
<tbody class="divide-y divide-gray-50">
|
|
81
|
+
{[
|
|
82
|
+
{ resource: 'Users', actions: ['create', 'read', 'update', 'delete'] },
|
|
83
|
+
{ resource: 'Roles', actions: ['create', 'read', 'update', 'delete'] },
|
|
84
|
+
{ resource: 'Content', actions: ['create', 'read', 'update', 'delete', 'publish'] },
|
|
85
|
+
{ resource: 'Media', actions: ['create', 'read', 'update', 'delete'] },
|
|
86
|
+
{ resource: 'Settings', actions: ['read', 'update'] },
|
|
87
|
+
{ resource: 'Audit Logs', actions: ['read'] },
|
|
88
|
+
].map((row) => (
|
|
89
|
+
<tr class="hover:bg-gray-50/50">
|
|
90
|
+
<td class="px-6 py-4 font-bold text-[#0b1222]">{row.resource}</td>
|
|
91
|
+
{defaultRoles.map((role) => {
|
|
92
|
+
const hasAll = permissions[role.name]?.includes('*');
|
|
93
|
+
const hasResource = permissions[role.name]?.some(p => p.startsWith(row.resource.toLowerCase() + ':') || p === '*');
|
|
94
|
+
return (
|
|
95
|
+
<td class="px-4 py-4 text-center">
|
|
96
|
+
{hasAll || hasResource ? (
|
|
97
|
+
<svg class="w-5 h-5 text-green-500 mx-auto" fill="currentColor" viewBox="0 0 20 20">
|
|
98
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
99
|
+
</svg>
|
|
100
|
+
) : (
|
|
101
|
+
<svg class="w-5 h-5 text-gray-200 mx-auto" fill="currentColor" viewBox="0 0 20 20">
|
|
102
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
103
|
+
</svg>
|
|
104
|
+
)}
|
|
105
|
+
</td>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</tr>
|
|
109
|
+
))}
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</AdminLayout>
|