@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 +171 -0
- package/package.json +1 -1
- package/src/middleware.ts +39 -0
- package/src/pages/api/auth/login.ts +3 -7
- package/src/pages/api/auth/logout.ts +17 -29
- package/src/pages/api/auth/register.ts +4 -19
- package/src/pages/api/auth/users.ts +88 -66
- package/src/pages/api/health.ts +49 -0
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
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 {
|
|
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
|
|
10
|
-
|
|
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 {
|
|
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
|
|
6
|
-
|
|
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
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
11
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
17
|
+
const adapter = await getAuthApi();
|
|
18
|
+
await adapter.connect();
|
|
31
19
|
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
66
|
-
totalDocs,
|
|
96
|
+
docs: users,
|
|
97
|
+
totalDocs: totalResult.count,
|
|
67
98
|
page,
|
|
68
99
|
limit,
|
|
69
|
-
totalPages: Math.ceil(
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|