@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.
Files changed (58) hide show
  1. package/package.json +17 -6
  2. package/src/components/Admin.tsx +50 -1
  3. package/src/components/LoginPage.tsx +223 -0
  4. package/src/components/layout/Sidebar.tsx +35 -0
  5. package/src/index.ts +35 -0
  6. package/src/middleware.ts +2 -0
  7. package/src/pages/api/auth/register.ts +133 -0
  8. package/src/styles/main.css +148 -0
  9. package/.astro/content.d.ts +0 -154
  10. package/.astro/settings.json +0 -5
  11. package/.astro/types.d.ts +0 -2
  12. package/astro.config.mjs +0 -28
  13. package/bun.lock +0 -1374
  14. package/dist/client/_astro/AdminLayout.DkDpng53.css +0 -1
  15. package/dist/client/_astro/AutoForm.3eJCmCJp.js +0 -1
  16. package/dist/client/_astro/client.DyczpTbx.js +0 -9
  17. package/dist/client/_astro/index.B02hbnpo.js +0 -1
  18. package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
  19. package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
  20. package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
  21. package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
  22. package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
  23. package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +0 -26
  24. package/dist/server/chunks/_id__BzI_o0qT.mjs +0 -50
  25. package/dist/server/chunks/_id__Cd-jOuY3.mjs +0 -238
  26. package/dist/server/chunks/_id__DvbD--iR.mjs +0 -992
  27. package/dist/server/chunks/_id__vpVaEo16.mjs +0 -128
  28. package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +0 -7
  29. package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +0 -4
  30. package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +0 -37
  31. package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +0 -74
  32. package/dist/server/chunks/config_CPXslElD.mjs +0 -4221
  33. package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +0 -89
  34. package/dist/server/chunks/index_CVqOkerS.mjs +0 -2960
  35. package/dist/server/chunks/index_CX8SQ4BF.mjs +0 -55
  36. package/dist/server/chunks/index_CYofDU51.mjs +0 -58
  37. package/dist/server/chunks/index_DdNRhuaM.mjs +0 -55
  38. package/dist/server/chunks/index_DupPvtIF.mjs +0 -42
  39. package/dist/server/chunks/index_YTS_M-B9.mjs +0 -263
  40. package/dist/server/chunks/index_YeCzuVps.mjs +0 -53
  41. package/dist/server/chunks/login_DLyqMRO8.mjs +0 -93
  42. package/dist/server/chunks/logout_CSbt5wea.mjs +0 -50
  43. package/dist/server/chunks/me_C04jlYhH.mjs +0 -41
  44. package/dist/server/chunks/new_BbQ9b55M.mjs +0 -92
  45. package/dist/server/chunks/node_9bvTewss.mjs +0 -1014
  46. package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +0 -3
  47. package/dist/server/chunks/sequence_9cl7AJy-.mjs +0 -2503
  48. package/dist/server/chunks/server_peBx9VXG.mjs +0 -8117
  49. package/dist/server/chunks/sharp_pmJ7nHES.mjs +0 -142
  50. package/dist/server/chunks/users_Dzddy_YR.mjs +0 -137
  51. package/dist/server/entry.mjs +0 -5
  52. package/dist/server/virtual_astro_middleware.mjs +0 -48
  53. package/public/fonts/Serotiva-Black.woff2 +0 -0
  54. package/public/fonts/Serotiva-Bold.woff2 +0 -0
  55. package/public/fonts/Serotiva-Medium.woff2 +0 -0
  56. package/public/fonts/Serotiva-Regular.woff2 +0 -0
  57. package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
  58. 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.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Admin dashboard for Kyro CMS",
7
- "main": "./dist/index.js",
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": "^10.0.4",
16
- "@astrojs/react": "5.0.2",
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": "6.1.3",
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
+ }
@@ -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
@@ -6,7 +6,9 @@ const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
6
6
  const PUBLIC_PATHS = [
7
7
  "/api/auth/login",
8
8
  "/api/auth/logout",
9
+ "/api/auth/register",
9
10
  "/api/auth/me",
11
+ "/api/auth/users",
10
12
  "/api/health",
11
13
  "/favicon.svg",
12
14
  ];
@@ -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
+ }