@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 +171 -0
- package/package.json +3 -3
- package/src/layouts/AuthLayout.astro +33 -0
- package/src/middleware.ts +72 -4
- package/src/pages/{index.astro → admin/index.astro} +2 -2
- package/src/pages/api/auth/login.ts +3 -7
- package/src/pages/api/auth/logout.ts +30 -30
- 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/src/pages/login.astro +82 -0
- package/src/pages/register.astro +102 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyro-cms/admin",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
|
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 '
|
|
3
|
-
import { collections } from "
|
|
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 {
|
|
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,48 +1,48 @@
|
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
return new Response(JSON.stringify({ success: true, session }), {
|
|
32
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
38
33
|
status: 200,
|
|
39
|
-
headers: {
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Set-Cookie": "auth_token=; path=/; max-age=0",
|
|
37
|
+
},
|
|
40
38
|
});
|
|
41
|
-
} catch
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 {
|
|
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
|
+
};
|
|
@@ -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>
|