@kyro-cms/admin 0.1.2 → 0.1.3
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/package.json +17 -6
- package/src/components/Admin.tsx +50 -1
- package/src/components/LoginPage.tsx +223 -0
- package/src/components/layout/Sidebar.tsx +35 -0
- package/src/index.ts +35 -0
- package/src/middleware.ts +2 -0
- package/src/pages/api/auth/register.ts +133 -0
- package/src/styles/main.css +148 -0
- package/.astro/content.d.ts +0 -154
- package/.astro/settings.json +0 -5
- package/.astro/types.d.ts +0 -2
- package/astro.config.mjs +0 -28
- package/bun.lock +0 -1374
- package/dist/client/_astro/AdminLayout.DkDpng53.css +0 -1
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +0 -1
- package/dist/client/_astro/client.DyczpTbx.js +0 -9
- package/dist/client/_astro/index.B02hbnpo.js +0 -1
- 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 +0 -26
- package/dist/server/chunks/_id__BzI_o0qT.mjs +0 -50
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +0 -238
- package/dist/server/chunks/_id__DvbD--iR.mjs +0 -992
- package/dist/server/chunks/_id__vpVaEo16.mjs +0 -128
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +0 -7
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +0 -4
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +0 -37
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +0 -74
- package/dist/server/chunks/config_CPXslElD.mjs +0 -4221
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +0 -89
- package/dist/server/chunks/index_CVqOkerS.mjs +0 -2960
- package/dist/server/chunks/index_CX8SQ4BF.mjs +0 -55
- package/dist/server/chunks/index_CYofDU51.mjs +0 -58
- package/dist/server/chunks/index_DdNRhuaM.mjs +0 -55
- package/dist/server/chunks/index_DupPvtIF.mjs +0 -42
- package/dist/server/chunks/index_YTS_M-B9.mjs +0 -263
- package/dist/server/chunks/index_YeCzuVps.mjs +0 -53
- package/dist/server/chunks/login_DLyqMRO8.mjs +0 -93
- package/dist/server/chunks/logout_CSbt5wea.mjs +0 -50
- package/dist/server/chunks/me_C04jlYhH.mjs +0 -41
- package/dist/server/chunks/new_BbQ9b55M.mjs +0 -92
- package/dist/server/chunks/node_9bvTewss.mjs +0 -1014
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +0 -3
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +0 -2503
- package/dist/server/chunks/server_peBx9VXG.mjs +0 -8117
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +0 -142
- package/dist/server/chunks/users_Dzddy_YR.mjs +0 -137
- package/dist/server/entry.mjs +0 -5
- package/dist/server/virtual_astro_middleware.mjs +0 -48
- 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/tsconfig.json +0 -12
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyro-cms/admin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Admin dashboard for Kyro CMS",
|
|
7
|
-
"main": "./
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"module": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
8
19
|
"scripts": {
|
|
9
20
|
"dev": "astro dev",
|
|
10
21
|
"build": "astro build",
|
|
@@ -12,11 +23,11 @@
|
|
|
12
23
|
"check": "astro check"
|
|
13
24
|
},
|
|
14
25
|
"dependencies": {
|
|
15
|
-
"@astrojs/node": "^
|
|
16
|
-
"@astrojs/react": "
|
|
26
|
+
"@astrojs/node": "^9.5.5",
|
|
27
|
+
"@astrojs/react": "^4.2.0",
|
|
17
28
|
"@kyro-cms/core": "^0.1.2",
|
|
18
29
|
"@tailwindcss/vite": "^4.0.0",
|
|
19
|
-
"astro": "
|
|
30
|
+
"astro": "^5.4.0",
|
|
20
31
|
"lucide-react": "^0.475.0",
|
|
21
32
|
"react": "^19.0.0",
|
|
22
33
|
"react-dom": "^19.0.0",
|
|
@@ -30,4 +41,4 @@
|
|
|
30
41
|
"peerDependencies": {
|
|
31
42
|
"@kyro-cms/core": "^0.1.2"
|
|
32
43
|
}
|
|
33
|
-
}
|
|
44
|
+
}
|
package/src/components/Admin.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { Sidebar } from "./layout/Sidebar";
|
|
|
4
4
|
import { ListView } from "./ListView";
|
|
5
5
|
import { DetailView } from "./DetailView";
|
|
6
6
|
import { CreateView } from "./CreateView";
|
|
7
|
+
import { LoginPage } from "./LoginPage";
|
|
7
8
|
import { Toast, ToastProvider } from "./ui/Toast";
|
|
8
9
|
import { ThemeProvider, type ThemeMode } from "./ThemeProvider";
|
|
9
10
|
import "../styles/main.css";
|
|
@@ -29,6 +30,14 @@ interface ToastMessage {
|
|
|
29
30
|
message: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
interface AuthUser {
|
|
34
|
+
id: string;
|
|
35
|
+
email: string;
|
|
36
|
+
role: string;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
function normalizeCollections(
|
|
33
42
|
input?: CollectionConfig[] | Record<string, CollectionConfig>,
|
|
34
43
|
): Record<string, CollectionConfig> {
|
|
@@ -66,6 +75,8 @@ function normalizeGlobals(
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export function Admin({ config, theme = "light", onThemeChange }: AdminProps) {
|
|
78
|
+
const [authenticated, setAuthenticated] = useState(false);
|
|
79
|
+
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
|
|
69
80
|
const [activeCollection, setActiveCollection] = useState<string | null>(null);
|
|
70
81
|
const [activeGlobal, setActiveGlobal] = useState<string | null>(null);
|
|
71
82
|
const [currentView, setCurrentView] = useState<View>("list");
|
|
@@ -75,12 +86,44 @@ export function Admin({ config, theme = "light", onThemeChange }: AdminProps) {
|
|
|
75
86
|
const collections = normalizeCollections(config.collections);
|
|
76
87
|
const globals = normalizeGlobals(config.globals);
|
|
77
88
|
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const token = localStorage.getItem("kyro_token");
|
|
91
|
+
const userStr = localStorage.getItem("kyro_user");
|
|
92
|
+
if (token && userStr) {
|
|
93
|
+
try {
|
|
94
|
+
const user = JSON.parse(userStr);
|
|
95
|
+
setAuthenticated(true);
|
|
96
|
+
setCurrentUser(user);
|
|
97
|
+
} catch {
|
|
98
|
+
localStorage.removeItem("kyro_token");
|
|
99
|
+
localStorage.removeItem("kyro_user");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
78
104
|
useEffect(() => {
|
|
79
105
|
const collectionKeys = Object.keys(collections);
|
|
80
106
|
if (collectionKeys.length > 0 && !activeCollection) {
|
|
81
107
|
setActiveCollection(collectionKeys[0]);
|
|
82
108
|
}
|
|
83
|
-
}, []);
|
|
109
|
+
}, [authenticated]);
|
|
110
|
+
|
|
111
|
+
const handleAuth = (token: string, user: AuthUser) => {
|
|
112
|
+
setAuthenticated(true);
|
|
113
|
+
setCurrentUser(user);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleLogout = async () => {
|
|
117
|
+
try {
|
|
118
|
+
await fetch("/api/auth/logout", { method: "POST" });
|
|
119
|
+
} catch {
|
|
120
|
+
} finally {
|
|
121
|
+
localStorage.removeItem("kyro_token");
|
|
122
|
+
localStorage.removeItem("kyro_user");
|
|
123
|
+
setAuthenticated(false);
|
|
124
|
+
setCurrentUser(null);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
84
127
|
|
|
85
128
|
const addToast = (type: ToastMessage["type"], message: string) => {
|
|
86
129
|
const id = Math.random().toString(36).substring(7);
|
|
@@ -182,6 +225,10 @@ export function Admin({ config, theme = "light", onThemeChange }: AdminProps) {
|
|
|
182
225
|
}
|
|
183
226
|
};
|
|
184
227
|
|
|
228
|
+
if (!authenticated) {
|
|
229
|
+
return <LoginPage onAuth={handleAuth} theme={theme} />;
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
return (
|
|
186
233
|
<ThemeProvider defaultMode={theme}>
|
|
187
234
|
<ToastProvider>
|
|
@@ -193,6 +240,8 @@ export function Admin({ config, theme = "light", onThemeChange }: AdminProps) {
|
|
|
193
240
|
activeGlobal={activeGlobal}
|
|
194
241
|
onCollectionClick={handleCollectionChange}
|
|
195
242
|
onGlobalClick={handleGlobalChange}
|
|
243
|
+
user={currentUser}
|
|
244
|
+
onLogout={handleLogout}
|
|
196
245
|
/>
|
|
197
246
|
<div className="kyro-main">
|
|
198
247
|
<div className="kyro-content">{renderContent()}</div>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { ThemeProvider, type ThemeMode } from "./ThemeProvider";
|
|
3
|
+
import { Toast, ToastProvider } from "./ui/Toast";
|
|
4
|
+
|
|
5
|
+
interface LocalToast {
|
|
6
|
+
id: string;
|
|
7
|
+
type: "success" | "error" | "info" | "warning";
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface LoginPageProps {
|
|
12
|
+
onAuth: (token: string, user: any) => void;
|
|
13
|
+
theme?: ThemeMode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type AuthMode = "login" | "register";
|
|
17
|
+
|
|
18
|
+
export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
|
|
19
|
+
const [mode, setMode] = useState<AuthMode>("login");
|
|
20
|
+
const [email, setEmail] = useState("");
|
|
21
|
+
const [password, setPassword] = useState("");
|
|
22
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [toasts, setToasts] = useState<LocalToast[]>([]);
|
|
25
|
+
const [isFirstUser, setIsFirstUser] = useState(false);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
checkIfFirstUser();
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const checkIfFirstUser = async () => {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch("/api/auth/users");
|
|
34
|
+
if (res.status === 401 || res.status === 404) {
|
|
35
|
+
setIsFirstUser(true);
|
|
36
|
+
setMode("register");
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
setIsFirstUser(true);
|
|
40
|
+
setMode("register");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const addToast = (type: LocalToast["type"], message: string) => {
|
|
45
|
+
const id = Math.random().toString(36).substring(7);
|
|
46
|
+
setToasts((prev) => [...prev, { id, type, message }]);
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
49
|
+
}, 5000);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
setLoading(true);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const endpoint =
|
|
58
|
+
mode === "login" ? "/api/auth/login" : "/api/auth/register";
|
|
59
|
+
const body: Record<string, string> = { email, password };
|
|
60
|
+
if (mode === "register") {
|
|
61
|
+
body.confirmPassword = confirmPassword;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const res = await fetch(endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
addToast("error", data.error || "Something went wrong");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (data.isFirstUser) {
|
|
78
|
+
setIsFirstUser(true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
localStorage.setItem("kyro_token", data.token);
|
|
82
|
+
localStorage.setItem("kyro_user", JSON.stringify(data.user));
|
|
83
|
+
addToast(
|
|
84
|
+
"success",
|
|
85
|
+
mode === "login" ? "Welcome back!" : "Account created!",
|
|
86
|
+
);
|
|
87
|
+
onAuth(data.token, data.user);
|
|
88
|
+
} catch {
|
|
89
|
+
addToast("error", "Connection failed");
|
|
90
|
+
} finally {
|
|
91
|
+
setLoading(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ThemeProvider defaultMode={theme}>
|
|
97
|
+
<ToastProvider>
|
|
98
|
+
<div className="kyro-login-page">
|
|
99
|
+
<div className="kyro-login-container">
|
|
100
|
+
<div className="kyro-login-header">
|
|
101
|
+
<h1 className="kyro-login-title">
|
|
102
|
+
{isFirstUser
|
|
103
|
+
? "Create Admin Account"
|
|
104
|
+
: mode === "login"
|
|
105
|
+
? "Sign In"
|
|
106
|
+
: "Create Account"}
|
|
107
|
+
</h1>
|
|
108
|
+
<p className="kyro-login-subtitle">
|
|
109
|
+
{isFirstUser
|
|
110
|
+
? "Set up your admin account to get started"
|
|
111
|
+
: mode === "login"
|
|
112
|
+
? "Enter your credentials to access the admin"
|
|
113
|
+
: "Create an account to access the admin"}
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<form onSubmit={handleSubmit} className="kyro-login-form">
|
|
118
|
+
<div className="kyro-form-group">
|
|
119
|
+
<label htmlFor="email">Email</label>
|
|
120
|
+
<input
|
|
121
|
+
id="email"
|
|
122
|
+
type="email"
|
|
123
|
+
value={email}
|
|
124
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
125
|
+
placeholder="admin@example.com"
|
|
126
|
+
required
|
|
127
|
+
autoComplete="email"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="kyro-form-group">
|
|
132
|
+
<label htmlFor="password">Password</label>
|
|
133
|
+
<input
|
|
134
|
+
id="password"
|
|
135
|
+
type="password"
|
|
136
|
+
value={password}
|
|
137
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
138
|
+
placeholder="••••••••"
|
|
139
|
+
required
|
|
140
|
+
minLength={8}
|
|
141
|
+
autoComplete={
|
|
142
|
+
mode === "login" ? "current-password" : "new-password"
|
|
143
|
+
}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{mode === "register" && (
|
|
148
|
+
<div className="kyro-form-group">
|
|
149
|
+
<label htmlFor="confirmPassword">Confirm Password</label>
|
|
150
|
+
<input
|
|
151
|
+
id="confirmPassword"
|
|
152
|
+
type="password"
|
|
153
|
+
value={confirmPassword}
|
|
154
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
155
|
+
placeholder="••••••••"
|
|
156
|
+
required
|
|
157
|
+
minLength={8}
|
|
158
|
+
autoComplete="new-password"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
type="submit"
|
|
165
|
+
className="kyro-btn kyro-btn-primary kyro-btn-lg"
|
|
166
|
+
disabled={loading}
|
|
167
|
+
>
|
|
168
|
+
{loading
|
|
169
|
+
? mode === "login"
|
|
170
|
+
? "Signing in..."
|
|
171
|
+
: "Creating account..."
|
|
172
|
+
: mode === "login"
|
|
173
|
+
? "Sign In"
|
|
174
|
+
: "Create Account"}
|
|
175
|
+
</button>
|
|
176
|
+
</form>
|
|
177
|
+
|
|
178
|
+
{!isFirstUser && (
|
|
179
|
+
<div className="kyro-login-footer">
|
|
180
|
+
<p>
|
|
181
|
+
{mode === "login" ? (
|
|
182
|
+
<>
|
|
183
|
+
Don't have an account?{" "}
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className="kyro-login-link"
|
|
187
|
+
onClick={() => setMode("register")}
|
|
188
|
+
>
|
|
189
|
+
Sign up
|
|
190
|
+
</button>
|
|
191
|
+
</>
|
|
192
|
+
) : (
|
|
193
|
+
<>
|
|
194
|
+
Already have an account?{" "}
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="kyro-login-link"
|
|
198
|
+
onClick={() => setMode("login")}
|
|
199
|
+
>
|
|
200
|
+
Sign in
|
|
201
|
+
</button>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
</p>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{toasts.map((toast) => (
|
|
210
|
+
<Toast
|
|
211
|
+
key={toast.id}
|
|
212
|
+
type={toast.type}
|
|
213
|
+
message={toast.message}
|
|
214
|
+
onClose={() =>
|
|
215
|
+
setToasts((prev) => prev.filter((t) => t.id !== toast.id))
|
|
216
|
+
}
|
|
217
|
+
/>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
</ToastProvider>
|
|
221
|
+
</ThemeProvider>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -10,6 +10,8 @@ interface SidebarProps {
|
|
|
10
10
|
onGlobalClick: (name: string) => void;
|
|
11
11
|
defaultCollapsed?: boolean;
|
|
12
12
|
onToggleCollapse?: (collapsed: boolean) => void;
|
|
13
|
+
user?: { id: string; email: string; role: string } | null;
|
|
14
|
+
onLogout?: () => void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface CollectionGroup {
|
|
@@ -204,6 +206,8 @@ export function Sidebar({
|
|
|
204
206
|
onGlobalClick,
|
|
205
207
|
defaultCollapsed = false,
|
|
206
208
|
onToggleCollapse,
|
|
209
|
+
user,
|
|
210
|
+
onLogout,
|
|
207
211
|
}: SidebarProps) {
|
|
208
212
|
const [collapsed, setCollapsed] = useState(() => {
|
|
209
213
|
if (typeof window !== "undefined") {
|
|
@@ -282,6 +286,37 @@ export function Sidebar({
|
|
|
282
286
|
</nav>
|
|
283
287
|
|
|
284
288
|
<div className="kyro-sidebar-footer">
|
|
289
|
+
{user && (
|
|
290
|
+
<div className="kyro-sidebar-user-info">
|
|
291
|
+
{!collapsed && (
|
|
292
|
+
<>
|
|
293
|
+
<div className="kyro-sidebar-user-email" title={user.email}>
|
|
294
|
+
{user.email}
|
|
295
|
+
</div>
|
|
296
|
+
<div className="kyro-sidebar-user-role">{user.role}</div>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
{onLogout && (
|
|
300
|
+
<button
|
|
301
|
+
className="kyro-sidebar-item kyro-sidebar-logout"
|
|
302
|
+
onClick={onLogout}
|
|
303
|
+
title={collapsed ? "Logout" : undefined}
|
|
304
|
+
>
|
|
305
|
+
<svg
|
|
306
|
+
width="18"
|
|
307
|
+
height="18"
|
|
308
|
+
viewBox="0 0 24 24"
|
|
309
|
+
fill="none"
|
|
310
|
+
stroke="currentColor"
|
|
311
|
+
strokeWidth="2"
|
|
312
|
+
>
|
|
313
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
|
|
314
|
+
</svg>
|
|
315
|
+
{!collapsed && <span>Logout</span>}
|
|
316
|
+
</button>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
285
320
|
<button
|
|
286
321
|
className="kyro-sidebar-item"
|
|
287
322
|
onClick={toggleCollapse}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { Admin } from "./components/Admin";
|
|
2
|
+
export { LoginPage } from "./components/LoginPage";
|
|
3
|
+
export { ListView } from "./components/ListView";
|
|
4
|
+
export { DetailView } from "./components/DetailView";
|
|
5
|
+
export { CreateView } from "./components/CreateView";
|
|
6
|
+
export { AutoForm } from "./components/AutoForm";
|
|
7
|
+
export {
|
|
8
|
+
ActionBar,
|
|
9
|
+
type ActionBarProps,
|
|
10
|
+
type DocumentStatus,
|
|
11
|
+
type SaveStatus,
|
|
12
|
+
} from "./components/ActionBar";
|
|
13
|
+
export { BulkActionsBar } from "./components/BulkActionsBar";
|
|
14
|
+
export { StatusBadge, CountBadge } from "./components/StatusBadge";
|
|
15
|
+
export { VersionHistoryPanel } from "./components/VersionHistoryPanel";
|
|
16
|
+
export {
|
|
17
|
+
ThemeProvider,
|
|
18
|
+
LightThemeProvider,
|
|
19
|
+
DarkThemeProvider,
|
|
20
|
+
useTheme,
|
|
21
|
+
type ThemeMode,
|
|
22
|
+
} from "./components/ThemeProvider";
|
|
23
|
+
export * from "./components/layout/Header";
|
|
24
|
+
export * from "./components/layout/Sidebar";
|
|
25
|
+
export * from "./components/ui/Button";
|
|
26
|
+
export * from "./components/ui/Badge";
|
|
27
|
+
export * from "./components/ui/Spinner";
|
|
28
|
+
export * from "./components/ui/Toast";
|
|
29
|
+
export {
|
|
30
|
+
Dropdown,
|
|
31
|
+
DropdownItem,
|
|
32
|
+
DropdownSeparator,
|
|
33
|
+
} from "./components/ui/Dropdown";
|
|
34
|
+
export { Modal, ConfirmModal } from "./components/ui/Modal";
|
|
35
|
+
export { SlidePanel } from "./components/ui/SlidePanel";
|
package/src/middleware.ts
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { RedisAuthAdapter } from "@kyro-cms/core";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
|
|
5
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
6
|
+
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "24h";
|
|
7
|
+
const ALLOW_REGISTRATION = process.env.KYRO_ALLOW_REGISTRATION !== "false";
|
|
8
|
+
|
|
9
|
+
async function getAuthApi() {
|
|
10
|
+
return new RedisAuthAdapter({
|
|
11
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
12
|
+
tls: process.env.REDIS_TLS === "true",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
17
|
+
try {
|
|
18
|
+
const body = (await request.json()) as {
|
|
19
|
+
email?: string;
|
|
20
|
+
password?: string;
|
|
21
|
+
confirmPassword?: string;
|
|
22
|
+
};
|
|
23
|
+
const { email, password, confirmPassword } = body;
|
|
24
|
+
|
|
25
|
+
if (!email || !password) {
|
|
26
|
+
return new Response(
|
|
27
|
+
JSON.stringify({ error: "Email and password required" }),
|
|
28
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (password !== confirmPassword) {
|
|
33
|
+
return new Response(JSON.stringify({ error: "Passwords do not match" }), {
|
|
34
|
+
status: 400,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (password.length < 8) {
|
|
40
|
+
return new Response(
|
|
41
|
+
JSON.stringify({ error: "Password must be at least 8 characters" }),
|
|
42
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const adapter = await getAuthApi();
|
|
47
|
+
await adapter.connect();
|
|
48
|
+
|
|
49
|
+
const existingUser = await adapter.findUserByEmail(email);
|
|
50
|
+
if (existingUser) {
|
|
51
|
+
await adapter.disconnect();
|
|
52
|
+
return new Response(
|
|
53
|
+
JSON.stringify({ error: "Email already registered" }),
|
|
54
|
+
{ status: 409, headers: { "Content-Type": "application/json" } },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isFirstUser = await checkIsFirstUser(adapter);
|
|
59
|
+
|
|
60
|
+
if (!isFirstUser && !ALLOW_REGISTRATION) {
|
|
61
|
+
await adapter.disconnect();
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({ error: "Registration is disabled" }),
|
|
64
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const passwordHash = await adapter.hashPassword(password);
|
|
69
|
+
const user = await adapter.createUser({
|
|
70
|
+
email,
|
|
71
|
+
passwordHash,
|
|
72
|
+
role: isFirstUser ? "super_admin" : "editor",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (isFirstUser) {
|
|
76
|
+
await adapter.updateUser(user.id, { emailVerified: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const session = await adapter.createSession(user.id, {
|
|
80
|
+
ipAddress: request.headers.get("x-forwarded-for") || "unknown",
|
|
81
|
+
userAgent: request.headers.get("user-agent") || "",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const token = jwt.sign(
|
|
85
|
+
{
|
|
86
|
+
sub: user.id,
|
|
87
|
+
email: user.email,
|
|
88
|
+
role: user.role,
|
|
89
|
+
tenantId: user.tenantId,
|
|
90
|
+
},
|
|
91
|
+
JWT_SECRET,
|
|
92
|
+
{ expiresIn: JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await adapter.disconnect();
|
|
96
|
+
|
|
97
|
+
const { passwordHash: _, ...safeUser } = user;
|
|
98
|
+
|
|
99
|
+
return new Response(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
success: true,
|
|
102
|
+
isFirstUser,
|
|
103
|
+
user: safeUser,
|
|
104
|
+
token,
|
|
105
|
+
refreshToken: session.refreshToken,
|
|
106
|
+
}),
|
|
107
|
+
{
|
|
108
|
+
status: 201,
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("Registration error:", error);
|
|
114
|
+
return new Response(JSON.stringify({ error: "Registration failed" }), {
|
|
115
|
+
status: 500,
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
async function checkIsFirstUser(adapter: RedisAuthAdapter): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
const redis = (adapter as any).redis;
|
|
124
|
+
if (!redis) return true;
|
|
125
|
+
|
|
126
|
+
const pattern = "kyro:auth:users:email:*";
|
|
127
|
+
const result = await redis.scan("0", "MATCH", pattern, "COUNT", "1");
|
|
128
|
+
const keys = result[1];
|
|
129
|
+
return keys.length === 0;
|
|
130
|
+
} catch {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|