@kyro-cms/admin 0.1.4 → 0.1.5

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.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Admin dashboard for Kyro CMS",
package/src/middleware.ts CHANGED
@@ -18,6 +18,45 @@ const PUBLIC_PREFIXES = ["/api/collections/", "/api/auth/"];
18
18
  export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
19
19
  const pathname = new URL(url).pathname;
20
20
 
21
+ // Handle root path redirection
22
+ if (pathname === "/") {
23
+ const authHeader = request.headers.get("authorization");
24
+ const token = authHeader?.startsWith("Bearer ")
25
+ ? authHeader.slice(7)
26
+ : null;
27
+
28
+ if (!token) {
29
+ // Redirect to admin login if not authenticated
30
+ return new Response(null, {
31
+ status: 302,
32
+ headers: {
33
+ Location: "/admin",
34
+ },
35
+ });
36
+ }
37
+
38
+ try {
39
+ // Verify token to get user info
40
+ const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload;
41
+
42
+ // Redirect to dashboard if authenticated
43
+ return new Response(null, {
44
+ status: 302,
45
+ headers: {
46
+ Location: "/admin/dashboard",
47
+ },
48
+ });
49
+ } catch {
50
+ // Invalid token, redirect to login
51
+ return new Response(null, {
52
+ status: 302,
53
+ headers: {
54
+ Location: "/admin",
55
+ },
56
+ });
57
+ }
58
+ }
59
+
21
60
  if (PUBLIC_PATHS.includes(pathname)) {
22
61
  return next();
23
62
  }
@@ -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,47 +1,35 @@
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
- });
21
- }
14
+ const authHeader = request.headers.get("authorization");
15
+ const token = authHeader?.startsWith("Bearer ")
16
+ ? authHeader.slice(7)
17
+ : null;
22
18
 
23
- const adapter = await getAuthApi();
24
- await adapter.connect();
25
-
26
- const session = await adapter.findSessionByToken(refreshToken);
27
- if (!session) {
19
+ if (token) {
20
+ const adapter = await getAuthApi();
21
+ await adapter.connect();
22
+ await adapter.deleteSession(token);
28
23
  await adapter.disconnect();
29
- return new Response(JSON.stringify({ error: "Invalid refresh token" }), {
30
- status: 401,
31
- headers: { "Content-Type": "application/json" },
32
- });
33
24
  }
34
25
 
35
- await adapter.disconnect();
36
-
37
- return new Response(JSON.stringify({ success: true, session }), {
26
+ return new Response(JSON.stringify({ success: true }), {
38
27
  status: 200,
39
28
  headers: { "Content-Type": "application/json" },
40
29
  });
41
- } catch (error) {
42
- console.error("Logout error:", error);
43
- return new Response(JSON.stringify({ error: "Logout failed" }), {
44
- status: 500,
30
+ } catch {
31
+ return new Response(JSON.stringify({ success: true }), {
32
+ status: 200,
45
33
  headers: { "Content-Type": "application/json" },
46
34
  });
47
35
  }
@@ -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
+ };