@kyro-cms/admin 0.1.4 → 0.1.6

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/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # @kyro-cms/admin
2
+
3
+ Admin dashboard for Kyro CMS — a React-based admin interface built with Astro.
4
+
5
+ ## Features
6
+
7
+ - **Authentication** — JWT-based login/logout with SQLite auth backend
8
+ - **Collection Management** — Create, edit, and manage content collections
9
+ - **User Management** — Manage users, roles, and permissions
10
+ - **Settings** — Configure CMS settings, globals, and plugins
11
+ - **Responsive** — Mobile-friendly dashboard with Tailwind CSS
12
+
13
+ ## Quick Start
14
+
15
+ ### Prerequisites
16
+
17
+ - Node.js 18+
18
+ - A Kyro CMS project with `@kyro-cms/core` installed
19
+
20
+ ### Development
21
+
22
+ ```bash
23
+ # Install dependencies
24
+ npm install
25
+
26
+ # Start dev server
27
+ npm run dev
28
+
29
+ # Build for production
30
+ npm run build
31
+
32
+ # Preview production build
33
+ npm run preview
34
+
35
+ # Type check
36
+ npm run check
37
+ ```
38
+
39
+ ### Integration with Kyro CMS
40
+
41
+ The admin dashboard is designed to work alongside a Kyro CMS project. In your Astro project:
42
+
43
+ 1. Install the admin package:
44
+
45
+ ```bash
46
+ npm install @kyro-cms/admin
47
+ ```
48
+
49
+ 2. Create an admin page at `src/pages/admin/index.astro`:
50
+
51
+ ```astro
52
+ ---
53
+ import { Admin } from '@kyro-cms/admin';
54
+ import config from '../../../kyro.config';
55
+ ---
56
+ <!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8" />
60
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
61
+ <title>Admin - My Kyro CMS</title>
62
+ </head>
63
+ <body>
64
+ <Admin client:load config={config} />
65
+ </body>
66
+ </html>
67
+ ```
68
+
69
+ 3. Configure your `astro.config.mjs` with the Node adapter:
70
+
71
+ ```js
72
+ import { defineConfig } from "astro/config";
73
+ import node from "@astrojs/node";
74
+
75
+ export default defineConfig({
76
+ output: "server",
77
+ adapter: node({ mode: "standalone" }),
78
+ });
79
+ ```
80
+
81
+ ## Authentication
82
+
83
+ The admin uses SQLite for auth by default (stored at `./data/auth.db`). No Redis or external services required.
84
+
85
+ ### Creating Your First Admin User
86
+
87
+ 1. Start the dev server: `npm run dev`
88
+ 2. Visit `http://localhost:4321/admin`
89
+ 3. Register with your email and password
90
+ 4. The first user automatically gets the `super_admin` role
91
+
92
+ ### Environment Variables
93
+
94
+ | Variable | Description | Default |
95
+ | ------------------------- | ------------------------------------------ | ------------------------- |
96
+ | `JWT_SECRET` | Secret for signing JWT tokens | `change-me-in-production` |
97
+ | `JWT_EXPIRES_IN` | Token expiration time | `24h` |
98
+ | `KYRO_AUTH_DB_PATH` | Path to auth SQLite database | `./data/auth.db` |
99
+ | `KYRO_ALLOW_REGISTRATION` | Allow public registration after first user | `true` |
100
+
101
+ ### Auth API Endpoints
102
+
103
+ | Endpoint | Method | Description |
104
+ | -------------------- | ------ | ---------------------------- |
105
+ | `/api/auth/login` | POST | Authenticate user |
106
+ | `/api/auth/register` | POST | Register new user |
107
+ | `/api/auth/logout` | POST | Invalidate session |
108
+ | `/api/auth/me` | GET | Get current user info |
109
+ | `/api/auth/users` | GET | List all users (admin only) |
110
+ | `/api/auth/users` | POST | Create new user (admin only) |
111
+
112
+ ## Project Structure
113
+
114
+ ```
115
+ admin/
116
+ ├── src/
117
+ │ ├── components/ # React UI components
118
+ │ ├── pages/ # Astro pages + API routes
119
+ │ │ └── api/ # REST API endpoints
120
+ │ │ └── auth/ # Authentication endpoints
121
+ │ ├── collections/ # Auth collection config
122
+ │ └── middleware.ts # Auth middleware
123
+ ├── public/ # Static assets
124
+ └── astro.config.mjs # Astro configuration
125
+ ```
126
+
127
+ ## Security
128
+
129
+ - **Password Hashing** — bcryptjs with 12 salt rounds
130
+ - **JWT Tokens** — Signed tokens with configurable expiration
131
+ - **Session Management** — Server-side session tracking via SQLite
132
+ - **Middleware Protection** — All non-auth routes require valid JWT
133
+ - **Password Policy** — Minimum 12 characters with complexity requirements
134
+
135
+ ## Scalability
136
+
137
+ ### Default Setup (Single Instance)
138
+
139
+ SQLite auth adapter handles everything in a single `./data/auth.db` file. Perfect for:
140
+
141
+ - Development
142
+ - Small to medium projects
143
+ - Single-server deployments
144
+
145
+ ### Multi-Instance / Horizontal Scaling
146
+
147
+ When running multiple Kyro CMS instances behind a load balancer, configure:
148
+
149
+ ```bash
150
+ # Shared auth database path (mounted volume, NFS, etc.)
151
+ KYRO_AUTH_DB_PATH=/shared/data/auth.db
152
+
153
+ # Enable write-ahead logging for concurrent access
154
+ # (automatically enabled by SQLiteAuthAdapter)
155
+ ```
156
+
157
+ ### High-Scale Production
158
+
159
+ For high-traffic deployments with many concurrent users:
160
+
161
+ 1. **Connection Pooling** — SQLite handles concurrent reads well, but writes are serialized. For write-heavy workloads, consider PostgreSQL with the Drizzle auth adapter.
162
+
163
+ 2. **Session Caching** — JWT tokens are self-contained, so session validation doesn't require database reads on every request.
164
+
165
+ 3. **Rate Limiting** — Currently in-memory per instance. For distributed rate limiting, use Redis or a shared SQLite file on fast storage.
166
+
167
+ 4. **Audit Logs** — Stored in SQLite. For high-volume audit logging, consider exporting to a dedicated log store.
168
+
169
+ ## License
170
+
171
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Admin dashboard for Kyro CMS",
@@ -25,7 +25,7 @@
25
25
  "dependencies": {
26
26
  "@astrojs/node": "^9.5.5",
27
27
  "@astrojs/react": "^4.2.0",
28
- "@kyro-cms/core": "^0.1.2",
28
+ "@kyro-cms/core": "^0.1.6",
29
29
  "@tailwindcss/vite": "^4.0.0",
30
30
  "astro": "^5.4.0",
31
31
  "lucide-react": "^0.475.0",
@@ -41,4 +41,4 @@
41
41
  "peerDependencies": {
42
42
  "@kyro-cms/core": "^0.1.2"
43
43
  }
44
- }
44
+ }
@@ -0,0 +1,33 @@
1
+ ---
2
+ import "../styles/main.css";
3
+
4
+ interface Props {
5
+ title: string;
6
+ }
7
+
8
+ const { title } = Astro.props;
9
+ ---
10
+
11
+ <!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16
+ <title>{title} - Kyro CMS</title>
17
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
18
+ </head>
19
+ <body class="bg-[#eaeff2] antialiased text-[#0b1222]">
20
+ <div class="min-h-screen flex items-center justify-center p-6">
21
+ <div class="w-full">
22
+ <!-- Logo -->
23
+ <div class="text-center mb-8">
24
+ <a href="/" class="inline-block">
25
+ <span class="text-4xl font-black tracking-tighter text-[#0b1222]">KYRO.</span>
26
+ </a>
27
+ </div>
28
+
29
+ <slot />
30
+ </div>
31
+ </div>
32
+ </body>
33
+ </html>
package/src/middleware.ts CHANGED
@@ -10,6 +10,8 @@ const PUBLIC_PATHS = [
10
10
  "/api/auth/me",
11
11
  "/api/auth/users",
12
12
  "/api/health",
13
+ "/login",
14
+ "/register",
13
15
  "/favicon.svg",
14
16
  ];
15
17
 
@@ -18,6 +20,75 @@ const PUBLIC_PREFIXES = ["/api/collections/", "/api/auth/"];
18
20
  export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
19
21
  const pathname = new URL(url).pathname;
20
22
 
23
+ // Helper to extract token from cookie or header
24
+ const getToken = (): string | null => {
25
+ // Check Authorization header first
26
+ const authHeader = request.headers.get("authorization");
27
+ if (authHeader?.startsWith("Bearer ")) {
28
+ return authHeader.slice(7);
29
+ }
30
+ // Check cookie
31
+ const cookies = request.headers.get("cookie") || "";
32
+ const match = cookies.match(/auth_token=([^;]+)/);
33
+ return match ? match[1] : null;
34
+ };
35
+
36
+ const token = getToken();
37
+
38
+ // Handle root path - redirect to admin for authenticated users
39
+ if (pathname === "/") {
40
+ if (!token) {
41
+ return new Response(null, {
42
+ status: 302,
43
+ headers: {
44
+ Location: "/login",
45
+ },
46
+ });
47
+ }
48
+
49
+ // Token exists - redirect to admin dashboard
50
+ try {
51
+ jwt.verify(token, JWT_SECRET);
52
+ return new Response(null, {
53
+ status: 302,
54
+ headers: {
55
+ Location: "/admin",
56
+ },
57
+ });
58
+ } catch {
59
+ return new Response(null, {
60
+ status: 302,
61
+ headers: {
62
+ Location: "/login",
63
+ },
64
+ });
65
+ }
66
+ }
67
+
68
+ // Handle /admin path - main dashboard
69
+ if (pathname === "/admin") {
70
+ if (!token) {
71
+ return new Response(null, {
72
+ status: 302,
73
+ headers: {
74
+ Location: "/login",
75
+ },
76
+ });
77
+ }
78
+
79
+ try {
80
+ jwt.verify(token, JWT_SECRET);
81
+ return next();
82
+ } catch {
83
+ return new Response(null, {
84
+ status: 302,
85
+ headers: {
86
+ Location: "/login",
87
+ },
88
+ });
89
+ }
90
+ }
91
+
21
92
  if (PUBLIC_PATHS.includes(pathname)) {
22
93
  return next();
23
94
  }
@@ -28,9 +99,6 @@ export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
28
99
  }
29
100
  }
30
101
 
31
- const authHeader = request.headers.get("authorization");
32
- const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
33
-
34
102
  if (!token) {
35
103
  return new Response(JSON.stringify({ error: "Authentication required" }), {
36
104
  status: 401,
@@ -39,7 +107,7 @@ export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
39
107
  }
40
108
 
41
109
  try {
42
- const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload;
110
+ jwt.verify(token, JWT_SECRET);
43
111
  return next();
44
112
  } catch {
45
113
  return new Response(JSON.stringify({ error: "Invalid or expired token" }), {
@@ -1,6 +1,6 @@
1
1
  ---
2
- import AdminLayout from '../layouts/AdminLayout.astro';
3
- import { collections } from "../lib/config";
2
+ import AdminLayout from '../../layouts/AdminLayout.astro';
3
+ import { collections } from "../../lib/config";
4
4
 
5
5
  const authCollections = ['users', 'roles', 'audit_logs'];
6
6
  const authItems = authCollections.map(slug => ({
@@ -1,14 +1,13 @@
1
1
  import type { APIRoute } from "astro";
2
- import { RedisAuthAdapter } from "@kyro-cms/core";
2
+ import { SQLiteAuthAdapter } from "@kyro-cms/core";
3
3
  import jwt from "jsonwebtoken";
4
4
 
5
5
  const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
6
6
  const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "24h";
7
7
 
8
8
  async function getAuthApi() {
9
- return new RedisAuthAdapter({
10
- url: process.env.REDIS_URL || "redis://localhost:6379",
11
- tls: process.env.REDIS_TLS === "true",
9
+ return new SQLiteAuthAdapter({
10
+ path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
12
11
  });
13
12
  }
14
13
 
@@ -49,7 +48,6 @@ export const POST: APIRoute = async ({ request }) => {
49
48
 
50
49
  const valid = await adapter.verifyPassword(password, user.passwordHash);
51
50
  if (!valid) {
52
- await adapter.recordFailedAttempt(user.id);
53
51
  await adapter.disconnect();
54
52
  return new Response(JSON.stringify({ error: "Invalid credentials" }), {
55
53
  status: 401,
@@ -57,8 +55,6 @@ export const POST: APIRoute = async ({ request }) => {
57
55
  });
58
56
  }
59
57
 
60
- await adapter.resetAttempts(user.id);
61
-
62
58
  const session = await adapter.createSession(user.id, {
63
59
  ipAddress: request.headers.get("x-forwarded-for") || "unknown",
64
60
  userAgent: request.headers.get("user-agent") || "",
@@ -1,48 +1,48 @@
1
1
  import type { APIRoute } from "astro";
2
- import { RedisAuthAdapter } from "@kyro-cms/core";
2
+ import { SQLiteAuthAdapter } from "@kyro-cms/core";
3
+
4
+ const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
3
5
 
4
6
  async function getAuthApi() {
5
- return new RedisAuthAdapter({
6
- url: process.env.REDIS_URL || "redis://localhost:6379",
7
- tls: process.env.REDIS_TLS === "true",
7
+ return new SQLiteAuthAdapter({
8
+ path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
8
9
  });
9
10
  }
10
11
 
11
12
  export const POST: APIRoute = async ({ request }) => {
12
13
  try {
13
- const body = (await request.json()) as { refreshToken?: string };
14
- const { refreshToken } = body;
15
-
16
- if (!refreshToken) {
17
- return new Response(JSON.stringify({ error: "Refresh token required" }), {
18
- status: 400,
19
- headers: { "Content-Type": "application/json" },
20
- });
14
+ // Check Authorization header or cookie for token
15
+ let token: string | null = null;
16
+ const authHeader = request.headers.get("authorization");
17
+ if (authHeader?.startsWith("Bearer ")) {
18
+ token = authHeader.slice(7);
19
+ } else {
20
+ const cookies = request.headers.get("cookie") || "";
21
+ const match = cookies.match(/auth_token=([^;]+)/);
22
+ token = match ? match[1] : null;
21
23
  }
22
24
 
23
- const adapter = await getAuthApi();
24
- await adapter.connect();
25
-
26
- const session = await adapter.findSessionByToken(refreshToken);
27
- if (!session) {
25
+ if (token) {
26
+ const adapter = await getAuthApi();
27
+ await adapter.connect();
28
+ await adapter.deleteSession(token);
28
29
  await adapter.disconnect();
29
- return new Response(JSON.stringify({ error: "Invalid refresh token" }), {
30
- status: 401,
31
- headers: { "Content-Type": "application/json" },
32
- });
33
30
  }
34
31
 
35
- await adapter.disconnect();
36
-
37
- return new Response(JSON.stringify({ success: true, session }), {
32
+ return new Response(JSON.stringify({ success: true }), {
38
33
  status: 200,
39
- headers: { "Content-Type": "application/json" },
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "Set-Cookie": "auth_token=; path=/; max-age=0",
37
+ },
40
38
  });
41
- } catch (error) {
42
- console.error("Logout error:", error);
43
- return new Response(JSON.stringify({ error: "Logout failed" }), {
44
- status: 500,
45
- headers: { "Content-Type": "application/json" },
39
+ } catch {
40
+ return new Response(JSON.stringify({ success: true }), {
41
+ status: 200,
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "Set-Cookie": "auth_token=; path=/; max-age=0",
45
+ },
46
46
  });
47
47
  }
48
48
  };
@@ -1,5 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
- import { RedisAuthAdapter } from "@kyro-cms/core";
2
+ import { SQLiteAuthAdapter } from "@kyro-cms/core";
3
3
  import jwt from "jsonwebtoken";
4
4
 
5
5
  const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
@@ -7,9 +7,8 @@ const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "24h";
7
7
  const ALLOW_REGISTRATION = process.env.KYRO_ALLOW_REGISTRATION !== "false";
8
8
 
9
9
  async function getAuthApi() {
10
- return new RedisAuthAdapter({
11
- url: process.env.REDIS_URL || "redis://localhost:6379",
12
- tls: process.env.REDIS_TLS === "true",
10
+ return new SQLiteAuthAdapter({
11
+ path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
13
12
  });
14
13
  }
15
14
 
@@ -55,7 +54,7 @@ export const POST: APIRoute = async ({ request }) => {
55
54
  );
56
55
  }
57
56
 
58
- const isFirstUser = await checkIsFirstUser(adapter);
57
+ const isFirstUser = !(await adapter.hasAnyUsers());
59
58
 
60
59
  if (!isFirstUser && !ALLOW_REGISTRATION) {
61
60
  await adapter.disconnect();
@@ -117,17 +116,3 @@ export const POST: APIRoute = async ({ request }) => {
117
116
  });
118
117
  }
119
118
  };
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
- }
@@ -1,72 +1,103 @@
1
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";
2
+ import { SQLiteAuthAdapter } from "@kyro-cms/core";
5
3
  import bcrypt from "bcryptjs";
6
- import { randomBytes } from "crypto";
7
4
 
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
- }
5
+ async function getAuthApi() {
6
+ return new SQLiteAuthAdapter({
7
+ path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
8
+ });
20
9
  }
21
10
 
22
- export const GET: APIRoute = async ({ url, request }) => {
23
- await ensureConnection();
24
-
11
+ export const GET: APIRoute = async ({ url }) => {
25
12
  const page = parseInt(url.searchParams.get("page") || "1");
26
13
  const limit = parseInt(url.searchParams.get("limit") || "25");
27
14
  const search = url.searchParams.get("search") || "";
28
15
 
29
16
  try {
30
- const pattern = search ? `*${search.toLowerCase()}*` : "*";
17
+ const adapter = await getAuthApi();
18
+ await adapter.connect();
31
19
 
32
- let cursor = "0";
20
+ // Get all users - SQLite doesn't have scan, so we need to query differently
21
+ // For now, we'll use a simple approach with email search
33
22
  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
- }
23
+
24
+ // If searching, we need to find matching emails
25
+ if (search) {
26
+ const result = (adapter as any).db
27
+ .prepare("SELECT * FROM kyro_users WHERE email LIKE ? LIMIT ? OFFSET ?")
28
+ .all(`%${search}%`, limit, (page - 1) * limit);
29
+
30
+ for (const row of result) {
31
+ const { password_hash, ...safeUser } = row;
32
+ users.push({
33
+ id: row.id,
34
+ email: row.email,
35
+ role: row.role,
36
+ tenantId: row.tenant_id,
37
+ emailVerified: row.email_verified === 1,
38
+ locked: row.locked === 1,
39
+ lastLogin: row.last_login,
40
+ failedLoginAttempts: row.failed_login_attempts || 0,
41
+ createdAt: row.created_at,
42
+ updatedAt: row.updated_at,
43
+ });
56
44
  }
57
- } while (cursor !== "0");
58
45
 
59
- const totalDocs = users.length;
60
- const startIndex = (page - 1) * limit;
61
- const paginatedUsers = users.slice(startIndex, startIndex + limit);
46
+ const totalResult = (adapter as any).db
47
+ .prepare("SELECT COUNT(*) as count FROM kyro_users WHERE email LIKE ?")
48
+ .get(`%${search}%`) as { count: number };
49
+
50
+ await adapter.disconnect();
51
+
52
+ return new Response(
53
+ JSON.stringify({
54
+ docs: users,
55
+ totalDocs: totalResult.count,
56
+ page,
57
+ limit,
58
+ totalPages: Math.ceil(totalResult.count / limit),
59
+ }),
60
+ {
61
+ status: 200,
62
+ headers: { "Content-Type": "application/json" },
63
+ },
64
+ );
65
+ }
66
+
67
+ // Get all users with pagination
68
+ const result = (adapter as any).db
69
+ .prepare("SELECT * FROM kyro_users LIMIT ? OFFSET ?")
70
+ .all(limit, (page - 1) * limit);
71
+
72
+ for (const row of result) {
73
+ const { password_hash, ...safeUser } = row;
74
+ users.push({
75
+ id: row.id,
76
+ email: row.email,
77
+ role: row.role,
78
+ tenantId: row.tenant_id,
79
+ emailVerified: row.email_verified === 1,
80
+ locked: row.locked === 1,
81
+ lastLogin: row.last_login,
82
+ failedLoginAttempts: row.failed_login_attempts || 0,
83
+ createdAt: row.created_at,
84
+ updatedAt: row.updated_at,
85
+ });
86
+ }
87
+
88
+ const totalResult = (adapter as any).db
89
+ .prepare("SELECT COUNT(*) as count FROM kyro_users")
90
+ .get() as { count: number };
91
+
92
+ await adapter.disconnect();
62
93
 
63
94
  return new Response(
64
95
  JSON.stringify({
65
- docs: paginatedUsers,
66
- totalDocs,
96
+ docs: users,
97
+ totalDocs: totalResult.count,
67
98
  page,
68
99
  limit,
69
- totalPages: Math.ceil(totalDocs / limit),
100
+ totalPages: Math.ceil(totalResult.count / limit),
70
101
  }),
71
102
  {
72
103
  status: 200,
@@ -90,10 +121,6 @@ export const GET: APIRoute = async ({ url, request }) => {
90
121
  };
91
122
 
92
123
  export const POST: APIRoute = async ({ request }) => {
93
- await ensureConnection();
94
-
95
- const { ipAddress, userAgent } = createAuditContext(request as any);
96
-
97
124
  try {
98
125
  const body = await request.json();
99
126
  const { email, password, role, tenantId } = body;
@@ -108,8 +135,12 @@ export const POST: APIRoute = async ({ request }) => {
108
135
  );
109
136
  }
110
137
 
111
- const existing = await redisAdapter.findUserByEmail(email);
138
+ const adapter = await getAuthApi();
139
+ await adapter.connect();
140
+
141
+ const existing = await adapter.findUserByEmail(email);
112
142
  if (existing) {
143
+ await adapter.disconnect();
113
144
  return new Response(JSON.stringify({ error: "Email already exists" }), {
114
145
  status: 400,
115
146
  headers: { "Content-Type": "application/json" },
@@ -117,23 +148,14 @@ export const POST: APIRoute = async ({ request }) => {
117
148
  }
118
149
 
119
150
  const passwordHash = await bcrypt.hash(password, 12);
120
- const user = await redisAdapter.createUser({
151
+ const user = await adapter.createUser({
121
152
  email,
122
153
  passwordHash,
123
154
  role: role || "customer",
124
155
  tenantId,
125
156
  });
126
157
 
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
- });
158
+ await adapter.disconnect();
137
159
 
138
160
  const { passwordHash: _, ...safeUser } = user;
139
161
  return new Response(JSON.stringify({ data: safeUser }), {
@@ -0,0 +1,49 @@
1
+ import type { APIRoute } from "astro";
2
+ import { SQLiteAuthAdapter } from "@kyro-cms/core";
3
+
4
+ async function getAuthApi() {
5
+ return new SQLiteAuthAdapter({
6
+ path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
7
+ });
8
+ }
9
+
10
+ export const GET: APIRoute = async () => {
11
+ try {
12
+ const adapter = await getAuthApi();
13
+ await adapter.connect();
14
+
15
+ const stats = await adapter.getStats();
16
+ await adapter.disconnect();
17
+
18
+ return new Response(
19
+ JSON.stringify({
20
+ status: "healthy",
21
+ auth: {
22
+ storage: "sqlite",
23
+ userCount: stats.userCount,
24
+ activeSessionCount: stats.activeSessionCount,
25
+ auditLogCount: stats.auditLogCount,
26
+ },
27
+ uptime: process.uptime(),
28
+ memory: process.memoryUsage(),
29
+ timestamp: new Date().toISOString(),
30
+ }),
31
+ {
32
+ status: 200,
33
+ headers: { "Content-Type": "application/json" },
34
+ },
35
+ );
36
+ } catch (error) {
37
+ return new Response(
38
+ JSON.stringify({
39
+ status: "unhealthy",
40
+ error: error instanceof Error ? error.message : "Unknown error",
41
+ timestamp: new Date().toISOString(),
42
+ }),
43
+ {
44
+ status: 503,
45
+ headers: { "Content-Type": "application/json" },
46
+ },
47
+ );
48
+ }
49
+ };
@@ -0,0 +1,82 @@
1
+ ---
2
+ import AuthLayout from '../layouts/AuthLayout.astro';
3
+ ---
4
+
5
+ <AuthLayout title="Sign In">
6
+ <div class="surface-tile p-8 w-full max-w-md">
7
+ <div class="text-center mb-8">
8
+ <h1 class="text-2xl font-black tracking-tight text-[#0b1222]">Welcome back</h1>
9
+ <p class="text-sm text-[#64748b] mt-2">Sign in to your account</p>
10
+ </div>
11
+
12
+ <form id="login-form" class="space-y-4">
13
+ <div>
14
+ <label for="email" class="block text-sm font-bold text-[#0b1222] mb-2">Email</label>
15
+ <input type="email" id="email" name="email" required
16
+ class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#0b1222]/20 focus:border-[#0b1222]"
17
+ placeholder="admin@example.com" />
18
+ </div>
19
+
20
+ <div>
21
+ <label for="password" class="block text-sm font-bold text-[#0b1222] mb-2">Password</label>
22
+ <input type="password" id="password" name="password" required
23
+ class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#0b1222]/20 focus:border-[#0b1222]"
24
+ placeholder="••••••••" />
25
+ </div>
26
+
27
+ <div id="form-message" class="hidden p-3 rounded-xl text-sm font-bold"></div>
28
+
29
+ <button type="submit" class="w-full py-3 bg-[#0b1222] text-white rounded-xl text-sm font-bold hover:bg-[#1a2332] transition-colors">
30
+ Sign In
31
+ </button>
32
+ </form>
33
+
34
+ <p class="text-center text-sm text-[#64748b] mt-6">
35
+ Don't have an account? <a href="/register" class="font-bold text-[#0b1222] hover:underline">Register</a>
36
+ </p>
37
+ </div>
38
+
39
+ <script is:inline>
40
+ document.getElementById('login-form')?.addEventListener('submit', async (e) => {
41
+ e.preventDefault();
42
+ const form = e.target;
43
+ const message = document.getElementById('form-message');
44
+ const button = form.querySelector('button[type="submit"]');
45
+
46
+ const email = form.email.value;
47
+ const password = form.password.value;
48
+
49
+ button.disabled = true;
50
+ button.textContent = 'Signing in...';
51
+
52
+ try {
53
+ const res = await fetch('/api/auth/login', {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ email, password })
57
+ });
58
+
59
+ const data = await res.json();
60
+
61
+ if (res.ok && data.success) {
62
+ // Set cookie for server-side auth
63
+ document.cookie = `auth_token=${data.token}; path=/; max-age=${60*60*24}; samesite=strict`;
64
+ localStorage.setItem('user', JSON.stringify(data.user));
65
+ message.textContent = 'Success! Redirecting...';
66
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-green-50 text-green-600';
67
+ setTimeout(() => { window.location.href = '/admin'; }, 500);
68
+ } else {
69
+ message.textContent = data.error || 'Login failed';
70
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
71
+ button.disabled = false;
72
+ button.textContent = 'Sign In';
73
+ }
74
+ } catch (err) {
75
+ message.textContent = 'Connection error';
76
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
77
+ button.disabled = false;
78
+ button.textContent = 'Sign In';
79
+ }
80
+ });
81
+ </script>
82
+ </AuthLayout>
@@ -0,0 +1,102 @@
1
+ ---
2
+ import AuthLayout from '../layouts/AuthLayout.astro';
3
+ ---
4
+
5
+ <AuthLayout title="Create Account">
6
+ <div class="surface-tile p-8 w-full max-w-md">
7
+ <div class="text-center mb-8">
8
+ <h1 class="text-2xl font-black tracking-tight text-[#0b1222]">Create your account</h1>
9
+ <p class="text-sm text-[#64748b] mt-2">Get started with Kyro CMS</p>
10
+ </div>
11
+
12
+ <form id="register-form" class="space-y-4">
13
+ <div>
14
+ <label for="email" class="block text-sm font-bold text-[#0b1222] mb-2">Email</label>
15
+ <input type="email" id="email" name="email" required
16
+ class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#0b1222]/20 focus:border-[#0b1222]"
17
+ placeholder="admin@example.com" />
18
+ </div>
19
+
20
+ <div>
21
+ <label for="password" class="block text-sm font-bold text-[#0b1222] mb-2">Password</label>
22
+ <input type="password" id="password" name="password" required minlength="8"
23
+ class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#0b1222]/20 focus:border-[#0b1222]"
24
+ placeholder="Minimum 8 characters" />
25
+ </div>
26
+
27
+ <div>
28
+ <label for="confirmPassword" class="block text-sm font-bold text-[#0b1222] mb-2">Confirm Password</label>
29
+ <input type="password" id="confirmPassword" name="confirmPassword" required minlength="8"
30
+ class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#0b1222]/20 focus:border-[#0b1222]"
31
+ placeholder="Confirm your password" />
32
+ </div>
33
+
34
+ <div id="form-message" class="hidden p-3 rounded-xl text-sm font-bold"></div>
35
+
36
+ <button type="submit" class="w-full py-3 bg-[#0b1222] text-white rounded-xl text-sm font-bold hover:bg-[#1a2332] transition-colors">
37
+ Create Account
38
+ </button>
39
+ </form>
40
+
41
+ <p class="text-center text-sm text-[#64748b] mt-6">
42
+ Already have an account? <a href="/login" class="font-bold text-[#0b1222] hover:underline">Sign in</a>
43
+ </p>
44
+ </div>
45
+
46
+ <script is:inline>
47
+ document.getElementById('register-form')?.addEventListener('submit', async (e) => {
48
+ e.preventDefault();
49
+ const form = e.target;
50
+ const message = document.getElementById('form-message');
51
+ const button = form.querySelector('button[type="submit"]');
52
+
53
+ const email = form.email.value;
54
+ const password = form.password.value;
55
+ const confirmPassword = form.confirmPassword.value;
56
+
57
+ if (password !== confirmPassword) {
58
+ message.textContent = 'Passwords do not match';
59
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
60
+ return;
61
+ }
62
+
63
+ if (password.length < 8) {
64
+ message.textContent = 'Password must be at least 8 characters';
65
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
66
+ return;
67
+ }
68
+
69
+ button.disabled = true;
70
+ button.textContent = 'Creating account...';
71
+
72
+ try {
73
+ const res = await fetch('/api/auth/register', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ email, password, confirmPassword })
77
+ });
78
+
79
+ const data = await res.json();
80
+
81
+ if (res.ok && data.success) {
82
+ // Set cookie for server-side auth
83
+ document.cookie = `auth_token=${data.token}; path=/; max-age=${60*60*24}; samesite=strict`;
84
+ localStorage.setItem('user', JSON.stringify(data.user));
85
+ message.textContent = data.isFirstUser ? 'Super admin account created!' : 'Account created successfully!';
86
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-green-50 text-green-600';
87
+ setTimeout(() => { window.location.href = '/admin'; }, 1000);
88
+ } else {
89
+ message.textContent = data.error || 'Registration failed';
90
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
91
+ button.disabled = false;
92
+ button.textContent = 'Create Account';
93
+ }
94
+ } catch (err) {
95
+ message.textContent = 'Connection error';
96
+ message.className = 'block p-3 rounded-xl text-sm font-bold bg-red-50 text-red-600';
97
+ button.disabled = false;
98
+ button.textContent = 'Create Account';
99
+ }
100
+ });
101
+ </script>
102
+ </AuthLayout>