@kyro-cms/admin 0.12.19 → 0.12.21

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 (60) hide show
  1. package/dist/index.cjs +24 -24
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +19 -19
  5. package/package.json +6 -6
  6. package/src/components/ApiKeysManager.tsx +177 -227
  7. package/src/components/AuditLogsPage.tsx +7 -7
  8. package/src/components/AuthorDashboard.tsx +235 -0
  9. package/src/components/AutoForm.tsx +15 -4
  10. package/src/components/CustomerDashboard.tsx +213 -0
  11. package/src/components/Dashboard.tsx +1 -1
  12. package/src/components/DashboardMetrics.tsx +74 -9
  13. package/src/components/DashboardQuickSections.tsx +237 -0
  14. package/src/components/ListView.tsx +3 -3
  15. package/src/components/MediaGallery.tsx +2 -2
  16. package/src/components/PluginsManager.tsx +1 -1
  17. package/src/components/SessionsManager.tsx +15 -15
  18. package/src/components/Sidebar.astro +564 -116
  19. package/src/components/UserManagement.tsx +43 -9
  20. package/src/components/UserMenu.tsx +123 -61
  21. package/src/components/autoform/AutoFormEditView.tsx +4 -4
  22. package/src/components/autoform/AutoFormHeader.tsx +2 -2
  23. package/src/components/fields/ArrayLayout.tsx +8 -8
  24. package/src/components/fields/CodeField.tsx +0 -1
  25. package/src/components/fields/TabsLayout.tsx +5 -2
  26. package/src/components/ui/CommandPalette.tsx +1 -1
  27. package/src/components/ui/Dropdown.tsx +71 -16
  28. package/src/components/ui/PageHeader.tsx +1 -1
  29. package/src/components/ui/Pagination.tsx +1 -1
  30. package/src/components/ui/SeoPreview.tsx +25 -21
  31. package/src/components/ui/Toast.tsx +3 -2
  32. package/src/components/ui/Toaster.tsx +1 -1
  33. package/src/components/users/UserDetail.tsx +74 -29
  34. package/src/components/users/UserForm.tsx +1 -1
  35. package/src/components/users/UsersList.tsx +2 -2
  36. package/src/integration.ts +4 -0
  37. package/src/layouts/AdminLayout.astro +26 -34
  38. package/src/layouts/AuthLayout.astro +18 -9
  39. package/src/lib/auth-server.ts +99 -0
  40. package/src/pages/403.astro +54 -35
  41. package/src/pages/[collection]/[id].astro +8 -0
  42. package/src/pages/[collection]/index.astro +7 -0
  43. package/src/pages/audit/index.astro +4 -1
  44. package/src/pages/auth/check-email.astro +146 -0
  45. package/src/pages/auth/forgot-password.astro +95 -0
  46. package/src/pages/auth/login.astro +29 -12
  47. package/src/pages/auth/register.astro +38 -12
  48. package/src/pages/auth/reset-password.astro +125 -0
  49. package/src/pages/auth/verify-email.astro +87 -0
  50. package/src/pages/index.astro +220 -121
  51. package/src/pages/keys.astro +3 -1
  52. package/src/pages/media.astro +3 -1
  53. package/src/pages/preview/[collection]/[id].astro +23 -73
  54. package/src/pages/roles/index.astro +134 -101
  55. package/src/pages/settings/[slug].astro +43 -3
  56. package/src/pages/users/[id].astro +5 -1
  57. package/src/pages/users/index.astro +4 -0
  58. package/src/pages/users/new.astro +4 -0
  59. package/src/pages/webhooks.astro +3 -1
  60. package/src/routes.ts +16 -0
@@ -4,28 +4,32 @@ interface SeoPreviewProps {
4
4
  title: string;
5
5
  description: string;
6
6
  slug: string;
7
+ collectionSlug?: string;
7
8
  }
8
9
 
9
- export const SeoPreview = ({ title, description, slug }: SeoPreviewProps) => (
10
- <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
11
- <div className="flex items-center gap-2 mb-2">
12
- <div className="w-7 h-7 bg-[var(--kyro-bg-secondary)] rounded-full flex items-center justify-center text-[10px] text-[var(--kyro-text-primary)] font-medium border border-[var(--kyro-border)]">
13
- K
14
- </div>
15
- <div className="flex flex-col">
16
- <span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
17
- kyro-cms.com
18
- </span>
19
- <span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
20
- https://kyro-cms.com › posts › {slug}
21
- </span>
10
+ export const SeoPreview = ({ title, description, slug, collectionSlug }: SeoPreviewProps) => {
11
+ const collectionName = collectionSlug || "posts";
12
+ return (
13
+ <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
14
+ <div className="flex items-center gap-2 mb-2">
15
+ <div className="w-7 h-7 bg-[var(--kyro-bg-secondary)] rounded-full flex items-center justify-center text-[10px] text-[var(--kyro-text-primary)] font-medium border border-[var(--kyro-border)]">
16
+ K
17
+ </div>
18
+ <div className="flex flex-col">
19
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
20
+ kyro-cms.com
21
+ </span>
22
+ <span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
23
+ https://kyro-cms.com › {collectionName} › {slug}
24
+ </span>
25
+ </div>
22
26
  </div>
27
+ <h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
28
+ {title}
29
+ </h3>
30
+ <p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
31
+ {description}
32
+ </p>
23
33
  </div>
24
- <h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
25
- {title}
26
- </h3>
27
- <p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
28
- {description}
29
- </p>
30
- </div>
31
- );
34
+ );
35
+ };
@@ -14,16 +14,17 @@ type ToastType = "success" | "error" | "warning" | "info";
14
14
  interface ToastProps {
15
15
  type: ToastType;
16
16
  message: string;
17
+ duration?: number;
17
18
  onClose: () => void;
18
19
  }
19
20
 
20
- export function Toast({ type, message, onClose }: ToastProps) {
21
+ export function Toast({ type, message, duration = 3000, onClose }: ToastProps) {
21
22
  const [isPaused, setIsPaused] = React.useState(false);
22
23
  const timerRef = React.useRef<NodeJS.Timeout | null>(null);
23
24
 
24
25
  const startTimer = () => {
25
26
  if (timerRef.current) clearTimeout(timerRef.current);
26
- timerRef.current = setTimeout(onClose, 5000);
27
+ timerRef.current = setTimeout(onClose, duration);
27
28
  };
28
29
 
29
30
  const clearTimer = () => {
@@ -9,7 +9,7 @@ export function Toaster() {
9
9
  <div className="kyro-toasts-container" style={{ position: "fixed", bottom: "24px", right: "24px", display: "flex", flexDirection: "column", gap: "12px", zIndex: 100000, pointerEvents: "none" }}>
10
10
  {toasts.map((t) => (
11
11
  <div key={t.id} style={{ pointerEvents: "auto" }}>
12
- <Toast type={t.type} message={t.message} onClose={() => removeToast(t.id)} />
12
+ <Toast type={t.type} message={t.message} duration={(t as any).duration} onClose={() => removeToast(t.id)} />
13
13
  </div>
14
14
  ))}
15
15
  </div>
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import { apiGet } from "../../lib/api";
3
- import { useUIStore, toast } from "../../lib/stores";
3
+ import { useUIStore, useAuthStore, toast } from "../../lib/stores";
4
4
  import { UploadField } from "../fields/UploadField";
5
5
  import { useTranslation } from "react-i18next";
6
6
  import { navigate } from '../../lib/navigate';
@@ -47,6 +47,27 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
47
47
  const [, setDeleting] = useState(false);
48
48
  const [locking, setLocking] = useState(false);
49
49
  const [isLocked, setIsLocked] = useState(user.locked || false);
50
+ const [currentUserRole, setCurrentUserRole] = useState<string>("");
51
+
52
+ useEffect(() => {
53
+ if ((window as any).__kyroAuth?.user?.role) {
54
+ setCurrentUserRole((window as any).__kyroAuth.user.role);
55
+ } else {
56
+ apiGet<any>("/api/auth/me")
57
+ .then((res) => {
58
+ if (res?.user?.role) setCurrentUserRole(res.user.role);
59
+ })
60
+ .catch(() => { });
61
+ }
62
+
63
+ const handler = (e: any) => {
64
+ if (e.detail?.user?.role) {
65
+ setCurrentUserRole(e.detail.user.role);
66
+ }
67
+ };
68
+ window.addEventListener("kyro:auth-ready", handler);
69
+ return () => window.removeEventListener("kyro:auth-ready", handler);
70
+ }, []);
50
71
 
51
72
  useEffect(() => {
52
73
  if (typeof avatar === "string" && /^[0-9a-f-]+$/i.test(avatar)) {
@@ -105,6 +126,15 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
105
126
 
106
127
  if (res.ok) {
107
128
  toast.success("User updated");
129
+ const currentMe = (window as any).__kyroAuth?.user || useAuthStore.getState().user;
130
+ if (currentMe && (currentMe.id === user.id || currentMe.email === user.email)) {
131
+ apiGet<any>("/api/auth/me").then((meRes) => {
132
+ const updatedUser = meRes?.user || meRes;
133
+ if (updatedUser) {
134
+ useAuthStore.getState().setUser(updatedUser);
135
+ }
136
+ });
137
+ }
108
138
  navigate(adminPath + "/users");
109
139
  } else {
110
140
  toast.error(data.error || "Failed to save user");
@@ -182,7 +212,7 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
182
212
 
183
213
  return (
184
214
  <div className="flex-1 overflow-y-auto space-y-8">
185
- <div className="surface-tile p-6 flex items-center justify-between">
215
+ <div className="surface-tile p-6 flex items-center justify-between rounded-lg">
186
216
  <div className="flex items-center gap-4">
187
217
  <div className="relative w-14 h-14 rounded-full bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] flex items-center justify-center font-bold text-xl overflow-hidden flex-shrink-0">
188
218
  {avatarUrl ? (
@@ -200,23 +230,25 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
200
230
  </p>
201
231
  </div>
202
232
  </div>
203
- <div className="flex gap-2">
204
- <button
205
- onClick={handleLockToggle}
206
- className="px-4 py-2 border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
207
- >
208
- {isLocked ? "Unlock User" : "Lock User"}
209
- </button>
210
- <button
211
- onClick={handleDelete}
212
- className="px-4 py-2 border border-red-200 rounded-xl text-sm font-bold text-red-600 hover:bg-red-50 transition-colors"
213
- >
214
- Delete
215
- </button>
216
- </div>
233
+ {(currentUserRole === "admin" || currentUserRole === "super_admin") && (
234
+ <div className="flex gap-2">
235
+ <button
236
+ onClick={handleLockToggle}
237
+ className="px-4 py-2 border border-[var(--kyro-border)] rounded-xl text-sm font-bold text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
238
+ >
239
+ {isLocked ? "Unlock User" : "Lock User"}
240
+ </button>
241
+ <button
242
+ onClick={handleDelete}
243
+ className="px-4 py-2 border border-red-200 rounded-xl text-sm font-bold text-red-600 hover:bg-red-50 transition-colors"
244
+ >
245
+ Delete
246
+ </button>
247
+ </div>
248
+ )}
217
249
  </div>
218
250
 
219
- <div className="surface-tile p-6">
251
+ <div className="surface-tile p-6 rounded-lg">
220
252
  <h2 className="text-lg font-bold text-[var(--kyro-text-primary)] tracking-tighter mb-6">
221
253
  Details
222
254
  </h2>
@@ -242,20 +274,33 @@ export function UserDetail({ user, apiPath, adminPath }: UserDetailProps) {
242
274
  </p>
243
275
  </div>
244
276
  <div>
245
- <label className="text-xs font-bold text-[var(--kyro-text-secondary)] tracking-wider">
277
+ <label className="text-xs font-bold text-[var(--kyro-text-secondary)] tracking-wider">
246
278
  Role
247
279
  </label>
248
- <select
249
- value={role}
250
- onChange={(e) => setRole(e.target.value)}
251
- className="mt-1 w-full px-3 py-2 border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)]"
252
- >
253
- {roleOptions.map((r) => (
254
- <option key={r} value={r}>
255
- {r}
256
- </option>
257
- ))}
258
- </select>
280
+ {(() => {
281
+ const isSuperAdmin = currentUserRole === "super_admin";
282
+ return (
283
+ <div>
284
+ <select
285
+ value={role}
286
+ disabled={!isSuperAdmin}
287
+ onChange={(e) => setRole(e.target.value)}
288
+ className="mt-1 w-full px-3 py-2 border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] disabled:opacity-60 disabled:cursor-not-allowed"
289
+ >
290
+ {roleOptions.map((r) => (
291
+ <option key={r} value={r}>
292
+ {r}
293
+ </option>
294
+ ))}
295
+ </select>
296
+ {!isSuperAdmin && (
297
+ <p className="text-[11px] text-[var(--kyro-text-secondary)] mt-1 font-medium">
298
+ Only Super Admin can modify user roles.
299
+ </p>
300
+ )}
301
+ </div>
302
+ );
303
+ })()}
259
304
  </div>
260
305
  <div>
261
306
  <label className="text-xs font-bold text-[var(--kyro-text-secondary)] tracking-wider">
@@ -89,7 +89,7 @@ export function UserForm({ mode, apiPath, adminPath, user }: UserFormProps) {
89
89
 
90
90
  return (
91
91
  <div className="flex-1 overflow-y-auto space-y-8">
92
- <div className="surface-tile p-6 flex items-center justify-between">
92
+ <div className="surface-tile p-6 flex items-center justify-between rounded-lg">
93
93
  <div>
94
94
  <h1 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
95
95
  {mode === "create" ? "Create User" : "Edit User"}
@@ -84,7 +84,7 @@ export function UsersList({
84
84
 
85
85
  return (
86
86
  <div className="flex-1 overflow-y-auto space-y-8">
87
- <div className="surface-tile p-6 flex items-center justify-between">
87
+ <div className="surface-tile p-6 flex items-center justify-between rounded-lg">
88
88
  <div>
89
89
  <h1 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
90
90
  Users
@@ -117,7 +117,7 @@ export function UsersList({
117
117
  </a>
118
118
  </div>
119
119
 
120
- <div className="surface-tile overflow-hidden">
120
+ <div className="surface-tile overflow-hidden rounded-lg">
121
121
  {users.length === 0 ? (
122
122
  <div className="px-8 py-16 text-center">
123
123
  <div className="flex flex-col items-center gap-4">
@@ -317,6 +317,10 @@ module.exports = debug;
317
317
  { pattern: "", entrypoint: "./pages/index.astro" },
318
318
  { pattern: "/login", entrypoint: "./pages/auth/login.astro" },
319
319
  { pattern: "/register", entrypoint: "./pages/auth/register.astro" },
320
+ { pattern: "/auth/check-email", entrypoint: "./pages/auth/check-email.astro" },
321
+ { pattern: "/auth/forgot-password", entrypoint: "./pages/auth/forgot-password.astro" },
322
+ { pattern: "/auth/reset-password", entrypoint: "./pages/auth/reset-password.astro" },
323
+ { pattern: "/auth/verify-email", entrypoint: "./pages/auth/verify-email.astro" },
320
324
  { pattern: "/media", entrypoint: "./pages/media.astro" },
321
325
  { pattern: "/users", entrypoint: "./pages/users/index.astro" },
322
326
  { pattern: "/users/new", entrypoint: "./pages/users/new.astro" },
@@ -112,50 +112,42 @@ const defaultLang = siteSettings?.defaultLanguage || "en";
112
112
  }
113
113
 
114
114
  // Navigation Guard - Check if user has read access to current page
115
- if (permissions) {
115
+ if (permissions && user) {
116
116
  const path = window.location.pathname;
117
117
  const relativePath = path.replace(adminPath, "");
118
-
119
- // Extract slug and type
120
- let slug = "";
121
- let type = "";
122
-
123
- if (relativePath.startsWith("/users")) {
124
- slug = "users";
125
- type = "collection";
118
+ const userRole = user.role || "";
119
+ const isSuperAdmin = userRole === "super_admin";
120
+ const isAdmin = userRole === "admin" || isSuperAdmin;
121
+
122
+ let hasAccess = true;
123
+
124
+ if (relativePath.startsWith("/roles")) {
125
+ if (!isSuperAdmin) hasAccess = false;
126
+ } else if (relativePath.startsWith("/plugins") || relativePath.startsWith("/keys") || relativePath.startsWith("/webhooks")) {
127
+ if (!isAdmin) hasAccess = false;
128
+ } else if (relativePath.startsWith("/users")) {
129
+ if (!isAdmin && permissions.collections?.users?.read !== true) hasAccess = false;
126
130
  } else if (relativePath.startsWith("/audit")) {
127
- slug = "audit_logs";
128
- type = "collection";
131
+ if (!isAdmin && permissions.collections?.audit_logs?.read !== true) hasAccess = false;
129
132
  } else if (relativePath.startsWith("/media")) {
130
- slug = "media";
131
- type = "collection";
132
- } else if (relativePath.startsWith("/settings/")) {
133
- slug = relativePath.split("/")[2];
134
- type = "global";
133
+ if (!isAdmin && permissions.collections?.media?.read !== true) hasAccess = false;
134
+ } else if (relativePath.startsWith("/settings")) {
135
+ if (!isAdmin) hasAccess = false;
135
136
  } else if (
136
137
  relativePath.includes("/") &&
137
138
  !relativePath.startsWith("/login") &&
138
- !relativePath.startsWith("/403")
139
+ !relativePath.startsWith("/403") &&
140
+ !relativePath.startsWith("/auth/")
139
141
  ) {
140
- // Dynamic collections: /[adminPath]/[collectionSlug]/...
141
- slug = relativePath.split("/")[1];
142
- type = "collection";
143
- }
144
-
145
- if (slug && type) {
146
- let hasAccess = true;
147
- if (type === "collection" && permissions.collections) {
148
- const p = permissions.collections[slug];
149
- if (p && p.read === false) hasAccess = false;
150
- } else if (type === "global" && permissions.globals) {
151
- const p = permissions.globals[slug];
152
- if (p && p.read === false) hasAccess = false;
142
+ const colSlug = relativePath.split("/")[1];
143
+ if (!isAdmin && colSlug && permissions.collections?.[colSlug]?.read !== true) {
144
+ hasAccess = false;
153
145
  }
146
+ }
154
147
 
155
- if (!hasAccess) {
156
- console.log("[AdminLayout] Access denied for", slug, "redirecting to 403");
157
- window.location.href = adminPath + "/403";
158
- }
148
+ if (!hasAccess) {
149
+ console.log("[AdminLayout] Access denied for", relativePath, "redirecting to 403");
150
+ window.location.href = adminPath + "/403";
159
151
  }
160
152
  }
161
153
  } catch (err) {
@@ -61,16 +61,25 @@ const defaultLang = siteSettings?.defaultLanguage || "en";
61
61
  />
62
62
  <script is:inline define:vars={{ adminPath, apiPath }}>
63
63
  (async () => {
64
- try {
65
- const res = await fetch(apiPath + "/auth/me", { credentials: "include" });
66
- if (res.ok) {
67
- const data = await res.json();
68
- if (data?.user) {
69
- window.location.href = adminPath;
70
- return;
64
+ const pathname = window.location.pathname;
65
+ const isAuthActionPage =
66
+ pathname.includes("/auth/check-email") ||
67
+ pathname.includes("/auth/verify-email") ||
68
+ pathname.includes("/auth/reset-password") ||
69
+ pathname.includes("/auth/forgot-password");
70
+
71
+ if (!isAuthActionPage) {
72
+ try {
73
+ const res = await fetch(apiPath + "/auth/me", { credentials: "include" });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ if (data?.user) {
77
+ window.location.href = adminPath;
78
+ return;
79
+ }
71
80
  }
72
- }
73
- } catch {}
81
+ } catch {}
82
+ }
74
83
  const getTheme = () => {
75
84
  const stored = localStorage.getItem("theme");
76
85
  if (stored) return stored;
@@ -0,0 +1,99 @@
1
+ import { apiPath, adminPath } from "./paths";
2
+
3
+ export interface AuthServerResult {
4
+ user: any | null;
5
+ permissions: any | null;
6
+ }
7
+
8
+ export interface RequireAuthOptions {
9
+ requireAdmin?: boolean;
10
+ requireSuperAdmin?: boolean;
11
+ collectionRead?: string;
12
+ collectionCreate?: string;
13
+ }
14
+
15
+ /**
16
+ * Resolves authenticated user and permissions directly on the server during Astro SSR requests.
17
+ * Uses dynamic apiPath to support custom API endpoint configurations.
18
+ */
19
+ export async function getAuthServer(request: Request): Promise<AuthServerResult> {
20
+ try {
21
+ const cookie = request.headers.get("cookie") || "";
22
+ if (!cookie) return { user: null, permissions: null };
23
+
24
+ const url = new URL(request.url);
25
+ const origin = url.origin;
26
+ const cleanApiPath = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
27
+
28
+ const [meRes, accessRes] = await Promise.all([
29
+ fetch(`${origin}${cleanApiPath}/auth/me`, {
30
+ headers: { cookie },
31
+ }).catch(() => null),
32
+ fetch(`${origin}${cleanApiPath}/auth/access`, {
33
+ headers: { cookie },
34
+ }).catch(() => null),
35
+ ]);
36
+
37
+ const meData = meRes && meRes.ok ? await meRes.json() : null;
38
+ const permissions = accessRes && accessRes.ok ? await accessRes.json() : null;
39
+
40
+ return {
41
+ user: meData?.user || null,
42
+ permissions,
43
+ };
44
+ } catch (err) {
45
+ console.error("[getAuthServer] Error resolving SSR auth:", err);
46
+ return { user: null, permissions: null };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Declarative server-side SSR route guard helper.
52
+ * Returns a 302 HTTP Response redirect if the authenticated user lacks permissions,
53
+ * or null if access is authorized.
54
+ */
55
+ export async function requireAuthServer(
56
+ request: Request,
57
+ options?: RequireAuthOptions
58
+ ): Promise<Response | null> {
59
+ const { user, permissions } = await getAuthServer(request);
60
+ if (!user) return null;
61
+
62
+ const isSuperAdmin = user.role === "super_admin";
63
+ const isAdmin = user.role === "admin" || isSuperAdmin;
64
+ const isCustomer = user.role === "customer";
65
+
66
+ let hasAccess = true;
67
+
68
+ if (options?.requireSuperAdmin && !isSuperAdmin) {
69
+ hasAccess = false;
70
+ } else if (options?.requireAdmin && !isAdmin) {
71
+ hasAccess = false;
72
+ } else if (options?.collectionRead) {
73
+ const col = options.collectionRead;
74
+ if ((col === "settings" || (col === "media" && isCustomer)) && !isAdmin) {
75
+ hasAccess = false;
76
+ } else if (col !== "settings") {
77
+ const url = new URL(request.url);
78
+ const isSelfUserAccess = col === "users" && !!user?.id && url.pathname.endsWith(`/users/${user.id}`);
79
+ const canRead = isAdmin || isSelfUserAccess || permissions?.collections?.[col]?.read === true;
80
+ if (!canRead) hasAccess = false;
81
+ }
82
+ } else if (options?.collectionCreate) {
83
+ const col = options.collectionCreate;
84
+ if (col === "settings" && !isAdmin) {
85
+ hasAccess = false;
86
+ } else if (col !== "settings") {
87
+ const canCreate = isAdmin || permissions?.collections?.[col]?.create === true;
88
+ if (!canCreate) hasAccess = false;
89
+ }
90
+ }
91
+
92
+ if (!hasAccess) {
93
+ const url = new URL(request.url);
94
+ const targetUrl = `${url.origin}${adminPath}/403`;
95
+ return Response.redirect(targetUrl, 302);
96
+ }
97
+
98
+ return null;
99
+ }
@@ -1,66 +1,85 @@
1
1
  ---
2
2
  import AdminLayout from "../layouts/AdminLayout.astro";
3
- import i18next from '../lib/i18n';
4
- import { adminPath } from "../lib/paths";
3
+ import i18next from "../lib/i18n";
4
+ import { adminPath, apiPath } from "../lib/paths";
5
+ import { getAuthServer } from "../lib/auth-server";
5
6
 
6
- // Ensure this page looks premium
7
+ const { user } = await getAuthServer(Astro.request);
8
+ const userEmail = user?.email || "";
9
+ const userRole = user?.role || "";
7
10
  ---
8
11
 
9
- <AdminLayout title={i18next.t("pages." + "Access Denied".replace(/ /g, ""), { defaultValue: "Access Denied" })}>
10
- <div class="flex-1 flex items-center justify-center p-8">
11
- <div class="max-w-md w-full text-center space-y-8">
12
- <div class="relative">
13
- <div class="absolute inset-0 bg-red-500/20 blur-3xl rounded-full"></div>
12
+ <AdminLayout title={i18next.t("pages." + "Access Denied".replace(/ /g, ""), { defaultValue: "Access Denied" })}>
13
+ <div class="flex-1 flex items-center justify-center p-6 min-h-[calc(100vh-120px)]">
14
+ <div
15
+ class="surface-tile max-w-md w-full p-8 rounded-2xl border border-[var(--kyro-border)] shadow-sm text-center space-y-6"
16
+ >
17
+ <!-- Icon & Status Tag -->
18
+ <div class="flex flex-col items-center gap-3">
14
19
  <div
15
- class="relative w-24 h-24 mx-auto bg-red-500/10 border border-red-500/20 rounded-3xl flex items-center justify-center text-red-500"
20
+ class="w-12 h-12 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center justify-center"
16
21
  >
17
- <svg
18
- class="w-12 h-12"
19
- fill="none"
20
- stroke="currentColor"
21
- viewBox="0 0 24 24"
22
- >
22
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
23
23
  <path
24
24
  stroke-linecap="round"
25
25
  stroke-linejoin="round"
26
- stroke-width="2"
26
+ stroke-width="1.75"
27
27
  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"
28
28
  ></path>
29
29
  </svg>
30
30
  </div>
31
+ <span
32
+ class="text-[10px] font-mono font-semibold tracking-widest uppercase px-3 py-1 bg-red-500/10 text-rose-500 rounded-full border border-red-500/20"
33
+ >
34
+ HTTP 403 · Restricted
35
+ </span>
31
36
  </div>
32
37
 
33
- <div class="space-y-2">
34
- <h1
35
- class="text-4xl font-bold tracking-tighter text-[var(--kyro-text-primary)]"
36
- >
37
- Access Denied
38
- </h1>
39
- <p class="text-[var(--kyro-text-secondary)] font-medium">
40
- You don't have the required permissions to access this page. Please
41
- contact your administrator if you believe this is an error.
38
+ <!-- Text Header -->
39
+ <div class="space-y-1.5">
40
+ <h1 class="text-base font-semibold text-[var(--kyro-text-primary)]">Access Restricted</h1>
41
+ <p class="text-xs text-[var(--kyro-text-secondary)] leading-relaxed max-w-xs mx-auto">
42
+ Your current account role does not have permission to view or manage this resource.
42
43
  </p>
43
44
  </div>
44
45
 
45
- <div class="pt-4">
46
+ <!-- Account Context Badge -->
47
+ {
48
+ userRole && (
49
+ <div class="p-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-left flex items-center justify-between text-xs">
50
+ <div class="min-w-0 pr-2">
51
+ <p class="text-[10px] capitalise font-bold tracking-wider text-[var(--kyro-text-muted)]">Signed In As</p>
52
+ <p class="font-medium text-[var(--kyro-text-primary)] truncate">{userEmail || "Authenticated User"}</p>
53
+ </div>
54
+ <span class="px-2.5 py-1 text-[10px] font-bold rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] capitalize tracking-wider flex-shrink-0">
55
+ {userRole.replace("_", " ")}
56
+ </span>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ <!-- Action Buttons -->
62
+ <div class="pt-1 flex items-center justify-center gap-2">
63
+ <button
64
+ onclick="window.history.back()"
65
+ class="px-4 py-2 text-xs font-semibold rounded-xl border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] transition-colors cursor-pointer"
66
+ >
67
+ Go Back
68
+ </button>
69
+
46
70
  <a
47
71
  href={adminPath}
48
- class="inline-flex items-center gap-2 px-8 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold transition-all hover:opacity-90 active:scale-95 shadow-lg"
72
+ class="px-4 py-2 text-xs font-semibold rounded-xl bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] hover:opacity-90 transition-opacity inline-flex items-center gap-1.5"
49
73
  >
50
- <svg
51
- class="w-4 h-4"
52
- fill="none"
53
- stroke="currentColor"
54
- viewBox="0 0 24 24"
55
- >
74
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
75
  <path
57
76
  stroke-linecap="round"
58
77
  stroke-linejoin="round"
59
- stroke-width="2.5"
78
+ stroke-width="2"
60
79
  d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
61
80
  ></path>
62
81
  </svg>
63
- Back to Dashboard
82
+ Dashboard
64
83
  </a>
65
84
  </div>
66
85
  </div>
@@ -25,6 +25,14 @@ if (!isSettings) {
25
25
  : `Edit ${config.singularLabel || config.label || collection}`;
26
26
  }
27
27
 
28
+ import { requireAuthServer } from "../../lib/auth-server";
29
+
30
+ if (collection) {
31
+ const reqOp = (!id || id === "new") ? "collectionCreate" : "collectionRead";
32
+ const redirect = await requireAuthServer(Astro.request, { [reqOp]: collection });
33
+ if (redirect) return redirect;
34
+ }
35
+
28
36
  import SettingsPageAstro from "../settings/[slug].astro";
29
37
  ---
30
38
 
@@ -21,6 +21,13 @@ if (!isSettings) {
21
21
  config = collections[collection];
22
22
  }
23
23
 
24
+ import { requireAuthServer } from "../../lib/auth-server";
25
+
26
+ if (collection) {
27
+ const redirect = await requireAuthServer(Astro.request, { collectionRead: collection });
28
+ if (redirect) return redirect;
29
+ }
30
+
24
31
  import SettingsIndexAstro from "../settings/index.astro";
25
32
 
26
33
  let docs: any[] = [];