@kyro-cms/admin 0.1.2
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/.astro/content.d.ts +154 -0
- package/.astro/settings.json +5 -0
- package/.astro/types.d.ts +2 -0
- package/astro.config.mjs +28 -0
- package/bun.lock +1374 -0
- package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
- package/dist/client/_astro/client.DyczpTbx.js +9 -0
- package/dist/client/_astro/index.B02hbnpo.js +1 -0
- package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
- package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
- package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
- package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
- package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
- package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
- package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
- package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
- package/dist/server/chunks/index_CYofDU51.mjs +58 -0
- package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
- package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
- package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
- package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
- package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
- package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
- package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
- package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
- package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
- package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
- package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
- package/dist/server/entry.mjs +5 -0
- package/dist/server/virtual_astro_middleware.mjs +48 -0
- package/package.json +33 -0
- package/public/fonts/Serotiva-Black.woff2 +0 -0
- package/public/fonts/Serotiva-Bold.woff2 +0 -0
- package/public/fonts/Serotiva-Medium.woff2 +0 -0
- package/public/fonts/Serotiva-Regular.woff2 +0 -0
- package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/src/collections/auth/index.ts +155 -0
- package/src/components/ActionBar.tsx +215 -0
- package/src/components/Admin.tsx +214 -0
- package/src/components/AutoForm.tsx +1123 -0
- package/src/components/BulkActionsBar.tsx +80 -0
- package/src/components/CreateView.tsx +99 -0
- package/src/components/DetailView.tsx +329 -0
- package/src/components/Icons.tsx +23 -0
- package/src/components/ListView.tsx +192 -0
- package/src/components/StatusBadge.tsx +76 -0
- package/src/components/ThemeProvider.tsx +155 -0
- package/src/components/VersionHistoryPanel.tsx +205 -0
- package/src/components/fields/CheckboxField.tsx +37 -0
- package/src/components/fields/DateField.tsx +42 -0
- package/src/components/fields/NumberField.tsx +44 -0
- package/src/components/fields/RelationshipField.tsx +87 -0
- package/src/components/fields/SelectField.tsx +56 -0
- package/src/components/fields/TextField.tsx +49 -0
- package/src/components/index.ts +30 -0
- package/src/components/layout/Breadcrumbs.tsx +36 -0
- package/src/components/layout/Header.tsx +37 -0
- package/src/components/layout/Layout.tsx +25 -0
- package/src/components/layout/Sidebar.tsx +462 -0
- package/src/components/ui/Badge.tsx +14 -0
- package/src/components/ui/Button.tsx +41 -0
- package/src/components/ui/Dropdown.tsx +82 -0
- package/src/components/ui/Modal.tsx +135 -0
- package/src/components/ui/SlidePanel.tsx +73 -0
- package/src/components/ui/Spinner.tsx +24 -0
- package/src/components/ui/Toast.tsx +78 -0
- package/src/layouts/AdminLayout.astro +197 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/dataStore.ts +111 -0
- package/src/middleware.ts +48 -0
- package/src/pages/[collection]/[id].astro +176 -0
- package/src/pages/[collection]/index.astro +180 -0
- package/src/pages/api/[collection]/[id].ts +258 -0
- package/src/pages/api/[collection]/index.ts +289 -0
- package/src/pages/api/auth/[id].ts +142 -0
- package/src/pages/api/auth/audit-logs.ts +80 -0
- package/src/pages/api/auth/login.ts +101 -0
- package/src/pages/api/auth/logout.ts +48 -0
- package/src/pages/api/auth/me.ts +36 -0
- package/src/pages/api/auth/users.ts +150 -0
- package/src/pages/audit/index.astro +110 -0
- package/src/pages/index.astro +225 -0
- package/src/pages/roles/index.astro +114 -0
- package/src/pages/users/[id].astro +174 -0
- package/src/pages/users/index.astro +142 -0
- package/src/pages/users/new.astro +91 -0
- package/src/styles/main.css +1449 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { dataStore } from "@/lib/dataStore";
|
|
3
|
+
import { collections } from "@/lib/config";
|
|
4
|
+
|
|
5
|
+
dataStore.initialize(collections);
|
|
6
|
+
|
|
7
|
+
const AUTH_COLLECTIONS = ["users", "roles", "audit_logs"];
|
|
8
|
+
|
|
9
|
+
async function getAuthApi() {
|
|
10
|
+
const { RedisAuthAdapter } = await import("@kyro-cms/core");
|
|
11
|
+
return new RedisAuthAdapter({
|
|
12
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
13
|
+
tls: process.env.REDIS_TLS === "true",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const GET: APIRoute = async ({ params, url }) => {
|
|
18
|
+
const collection = params.collection as string;
|
|
19
|
+
|
|
20
|
+
if (AUTH_COLLECTIONS.includes(collection)) {
|
|
21
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
22
|
+
const limit = parseInt(url.searchParams.get("limit") || "25");
|
|
23
|
+
const search = url.searchParams.get("search") || "";
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const adapter = await getAuthApi();
|
|
27
|
+
await adapter.connect();
|
|
28
|
+
|
|
29
|
+
if (collection === "users") {
|
|
30
|
+
const pattern = search ? `*${search.toLowerCase()}*` : "*";
|
|
31
|
+
let cursor = "0";
|
|
32
|
+
const users: any[] = [];
|
|
33
|
+
const seenIds = new Set<string>();
|
|
34
|
+
|
|
35
|
+
do {
|
|
36
|
+
const [nextCursor, keys] = await (adapter as any).redis.scan(
|
|
37
|
+
cursor,
|
|
38
|
+
"MATCH",
|
|
39
|
+
"kyro:auth:users:email:*",
|
|
40
|
+
"COUNT",
|
|
41
|
+
100,
|
|
42
|
+
);
|
|
43
|
+
cursor = nextCursor;
|
|
44
|
+
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
const userId = await (adapter as any).redis.get(key);
|
|
47
|
+
if (userId && !seenIds.has(userId)) {
|
|
48
|
+
seenIds.add(userId);
|
|
49
|
+
const user = await adapter.findUserById(userId);
|
|
50
|
+
if (user) {
|
|
51
|
+
const { passwordHash, ...safeUser } = user;
|
|
52
|
+
users.push(safeUser);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} while (cursor !== "0");
|
|
57
|
+
|
|
58
|
+
const totalDocs = users.length;
|
|
59
|
+
const startIndex = (page - 1) * limit;
|
|
60
|
+
const paginatedUsers = users.slice(startIndex, startIndex + limit);
|
|
61
|
+
|
|
62
|
+
await adapter.disconnect();
|
|
63
|
+
|
|
64
|
+
return new Response(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
docs: paginatedUsers,
|
|
67
|
+
totalDocs,
|
|
68
|
+
page,
|
|
69
|
+
limit,
|
|
70
|
+
totalPages: Math.ceil(totalDocs / limit),
|
|
71
|
+
}),
|
|
72
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (collection === "roles") {
|
|
77
|
+
const defaultRoles = [
|
|
78
|
+
{
|
|
79
|
+
id: "super_admin",
|
|
80
|
+
name: "super_admin",
|
|
81
|
+
level: 100,
|
|
82
|
+
inherits: ["admin"],
|
|
83
|
+
description: "Full system access across all tenants",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "admin",
|
|
87
|
+
name: "admin",
|
|
88
|
+
level: 90,
|
|
89
|
+
inherits: ["editor"],
|
|
90
|
+
description: "Full tenant access with all content permissions",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "editor",
|
|
94
|
+
name: "editor",
|
|
95
|
+
level: 70,
|
|
96
|
+
inherits: ["author"],
|
|
97
|
+
description: "Edit and publish all content",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "author",
|
|
101
|
+
name: "author",
|
|
102
|
+
level: 50,
|
|
103
|
+
inherits: ["customer"],
|
|
104
|
+
description: "Create and edit own content",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "customer",
|
|
108
|
+
name: "customer",
|
|
109
|
+
level: 30,
|
|
110
|
+
inherits: [],
|
|
111
|
+
description: "Access own data and make purchases",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "guest",
|
|
115
|
+
name: "guest",
|
|
116
|
+
level: 10,
|
|
117
|
+
inherits: [],
|
|
118
|
+
description: "Public read-only access",
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
await adapter.disconnect();
|
|
123
|
+
|
|
124
|
+
return new Response(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
docs: defaultRoles,
|
|
127
|
+
totalDocs: defaultRoles.length,
|
|
128
|
+
page,
|
|
129
|
+
limit,
|
|
130
|
+
totalPages: 1,
|
|
131
|
+
}),
|
|
132
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (collection === "audit_logs") {
|
|
137
|
+
const logs = await (adapter as any).redis.keys("kyro:auth:audit:*");
|
|
138
|
+
const auditLogs: any[] = [];
|
|
139
|
+
|
|
140
|
+
for (const key of logs.slice(0, 100)) {
|
|
141
|
+
const logData = await (adapter as any).redis.hgetall(key);
|
|
142
|
+
if (logData) {
|
|
143
|
+
auditLogs.push({
|
|
144
|
+
id: logData.id,
|
|
145
|
+
action: logData.action,
|
|
146
|
+
userId: logData.userId,
|
|
147
|
+
userEmail: logData.userEmail,
|
|
148
|
+
role: logData.role,
|
|
149
|
+
resource: logData.resource,
|
|
150
|
+
ipAddress: logData.ipAddress,
|
|
151
|
+
userAgent: logData.userAgent,
|
|
152
|
+
success: logData.success === "true",
|
|
153
|
+
error: logData.error,
|
|
154
|
+
timestamp: logData.timestamp,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sortedLogs = auditLogs.sort(
|
|
160
|
+
(a, b) =>
|
|
161
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const totalDocs = sortedLogs.length;
|
|
165
|
+
const startIndex = (page - 1) * limit;
|
|
166
|
+
const paginatedLogs = sortedLogs.slice(startIndex, startIndex + limit);
|
|
167
|
+
|
|
168
|
+
await adapter.disconnect();
|
|
169
|
+
|
|
170
|
+
return new Response(
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
docs: paginatedLogs,
|
|
173
|
+
totalDocs,
|
|
174
|
+
page,
|
|
175
|
+
limit,
|
|
176
|
+
totalPages: Math.ceil(totalDocs / limit) || 1,
|
|
177
|
+
}),
|
|
178
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await adapter.disconnect();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(`Error fetching ${collection}:`, error);
|
|
185
|
+
return new Response(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
error: `Failed to fetch ${collection}`,
|
|
188
|
+
docs: [],
|
|
189
|
+
totalDocs: 0,
|
|
190
|
+
}),
|
|
191
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
197
|
+
const limit = parseInt(url.searchParams.get("limit") || "25");
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = dataStore.find(collection, { page, limit });
|
|
201
|
+
return new Response(JSON.stringify(result), {
|
|
202
|
+
status: 200,
|
|
203
|
+
headers: { "Content-Type": "application/json" },
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return new Response(
|
|
207
|
+
JSON.stringify({ error: "Failed to fetch documents" }),
|
|
208
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const POST: APIRoute = async ({ params, request }) => {
|
|
214
|
+
const collection = params.collection as string;
|
|
215
|
+
|
|
216
|
+
if (AUTH_COLLECTIONS.includes(collection)) {
|
|
217
|
+
try {
|
|
218
|
+
const adapter = await getAuthApi();
|
|
219
|
+
await adapter.connect();
|
|
220
|
+
|
|
221
|
+
const body = await request.json();
|
|
222
|
+
|
|
223
|
+
if (collection === "users") {
|
|
224
|
+
const { email, password, role, tenantId } = body;
|
|
225
|
+
|
|
226
|
+
if (!email || !password) {
|
|
227
|
+
await adapter.disconnect();
|
|
228
|
+
return new Response(
|
|
229
|
+
JSON.stringify({ error: "Email and password are required" }),
|
|
230
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const existing = await adapter.findUserByEmail(email);
|
|
235
|
+
if (existing) {
|
|
236
|
+
await adapter.disconnect();
|
|
237
|
+
return new Response(
|
|
238
|
+
JSON.stringify({ error: "Email already exists" }),
|
|
239
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const passwordHash = await adapter.hashPassword(password);
|
|
244
|
+
const user = await adapter.createUser({
|
|
245
|
+
email,
|
|
246
|
+
passwordHash,
|
|
247
|
+
role: role || "customer",
|
|
248
|
+
tenantId,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await adapter.disconnect();
|
|
252
|
+
|
|
253
|
+
const { passwordHash: _, ...safeUser } = user;
|
|
254
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
255
|
+
status: 201,
|
|
256
|
+
headers: { "Content-Type": "application/json" },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await adapter.disconnect();
|
|
261
|
+
return new Response(
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
error: `Collection ${collection} does not support POST`,
|
|
264
|
+
}),
|
|
265
|
+
{ status: 405, headers: { "Content-Type": "application/json" } },
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
console.error(`Error creating ${collection}:`, error);
|
|
269
|
+
return new Response(
|
|
270
|
+
JSON.stringify({ error: `Failed to create ${collection}` }),
|
|
271
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const body = await request.json();
|
|
278
|
+
const doc = dataStore.create(collection, body);
|
|
279
|
+
return new Response(JSON.stringify({ data: doc }), {
|
|
280
|
+
status: 201,
|
|
281
|
+
headers: { "Content-Type": "application/json" },
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return new Response(
|
|
285
|
+
JSON.stringify({ error: "Failed to create document" }),
|
|
286
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
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";
|
|
5
|
+
import bcrypt from "bcryptjs";
|
|
6
|
+
|
|
7
|
+
const redisAdapter = new RedisAuthAdapter({
|
|
8
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const auditLogger = new AuditLogger(redisAdapter as any);
|
|
12
|
+
|
|
13
|
+
async function ensureConnection() {
|
|
14
|
+
try {
|
|
15
|
+
await redisAdapter.connect();
|
|
16
|
+
} catch (e) {
|
|
17
|
+
// Connection might already be established
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
22
|
+
await ensureConnection();
|
|
23
|
+
|
|
24
|
+
const { id } = params;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const user = await redisAdapter.findUserById(id!);
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
31
|
+
status: 404,
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { passwordHash, ...safeUser } = user;
|
|
37
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error("Error fetching user:", error);
|
|
43
|
+
return new Response(JSON.stringify({ error: "Failed to fetch user" }), {
|
|
44
|
+
status: 500,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const PUT: APIRoute = async ({ params, request }) => {
|
|
51
|
+
await ensureConnection();
|
|
52
|
+
|
|
53
|
+
const { id } = params;
|
|
54
|
+
const { ipAddress, userAgent } = createAuditContext(request as any);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const body = await request.json();
|
|
58
|
+
const { email, role, tenantId, locked, emailVerified } = body;
|
|
59
|
+
|
|
60
|
+
const existing = await redisAdapter.findUserById(id!);
|
|
61
|
+
if (!existing) {
|
|
62
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
63
|
+
status: 404,
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const updateData: any = {};
|
|
69
|
+
if (email !== undefined) updateData.email = email;
|
|
70
|
+
if (role !== undefined) updateData.role = role;
|
|
71
|
+
if (tenantId !== undefined) updateData.tenantId = tenantId;
|
|
72
|
+
if (locked !== undefined) updateData.locked = locked;
|
|
73
|
+
if (emailVerified !== undefined) updateData.emailVerified = emailVerified;
|
|
74
|
+
|
|
75
|
+
const user = await redisAdapter.updateUser(id!, updateData);
|
|
76
|
+
|
|
77
|
+
await auditLogger.log({
|
|
78
|
+
action: "user_update",
|
|
79
|
+
userId: id,
|
|
80
|
+
userEmail: existing.email,
|
|
81
|
+
role: existing.role,
|
|
82
|
+
resource: "users",
|
|
83
|
+
ipAddress,
|
|
84
|
+
userAgent,
|
|
85
|
+
success: true,
|
|
86
|
+
metadata: { updateData },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const { passwordHash, ...safeUser } = user!;
|
|
90
|
+
return new Response(JSON.stringify({ data: safeUser }), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("Error updating user:", error);
|
|
96
|
+
return new Response(JSON.stringify({ error: "Failed to update user" }), {
|
|
97
|
+
status: 500,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const DELETE: APIRoute = async ({ params, request }) => {
|
|
104
|
+
await ensureConnection();
|
|
105
|
+
|
|
106
|
+
const { id } = params;
|
|
107
|
+
const { ipAddress, userAgent } = createAuditContext(request as any);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const existing = await redisAdapter.findUserById(id!);
|
|
111
|
+
if (!existing) {
|
|
112
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
113
|
+
status: 404,
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await redisAdapter.deleteUser(id!);
|
|
119
|
+
|
|
120
|
+
await auditLogger.log({
|
|
121
|
+
action: "user_delete",
|
|
122
|
+
userId: id,
|
|
123
|
+
userEmail: existing.email,
|
|
124
|
+
role: existing.role,
|
|
125
|
+
resource: "users",
|
|
126
|
+
ipAddress,
|
|
127
|
+
userAgent,
|
|
128
|
+
success: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers: { "Content-Type": "application/json" },
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error("Error deleting user:", error);
|
|
137
|
+
return new Response(JSON.stringify({ error: "Failed to delete user" }), {
|
|
138
|
+
status: 500,
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { RedisAuthAdapter } from "@kyro-cms/core";
|
|
3
|
+
import { AuditLogger } from "@kyro-cms/core";
|
|
4
|
+
|
|
5
|
+
const redisAdapter = new RedisAuthAdapter({
|
|
6
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const auditLogger = new AuditLogger(redisAdapter as any);
|
|
10
|
+
|
|
11
|
+
async function ensureConnection() {
|
|
12
|
+
try {
|
|
13
|
+
await redisAdapter.connect();
|
|
14
|
+
} catch (e) {
|
|
15
|
+
// Connection might already be established
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
20
|
+
await ensureConnection();
|
|
21
|
+
|
|
22
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
23
|
+
const limit = parseInt(url.searchParams.get("limit") || "25");
|
|
24
|
+
const action = url.searchParams.get("action") || "";
|
|
25
|
+
const userId = url.searchParams.get("userId") || "";
|
|
26
|
+
const success = url.searchParams.get("success");
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const logs = await auditLogger.getLogs({
|
|
30
|
+
action: action || undefined,
|
|
31
|
+
userId: userId || undefined,
|
|
32
|
+
success:
|
|
33
|
+
success === "true" ? true : success === "false" ? false : undefined,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const thirtyDaysAgo = new Date();
|
|
37
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
38
|
+
|
|
39
|
+
const filteredLogs = logs.filter((log: any) => {
|
|
40
|
+
const logDate = new Date(log.timestamp);
|
|
41
|
+
return logDate >= thirtyDaysAgo;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const sortedLogs = filteredLogs.sort(
|
|
45
|
+
(a: any, b: any) =>
|
|
46
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const totalDocs = sortedLogs.length;
|
|
50
|
+
const startIndex = (page - 1) * limit;
|
|
51
|
+
const paginatedLogs = sortedLogs.slice(startIndex, startIndex + limit);
|
|
52
|
+
|
|
53
|
+
return new Response(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
docs: paginatedLogs,
|
|
56
|
+
totalDocs,
|
|
57
|
+
page,
|
|
58
|
+
limit,
|
|
59
|
+
totalPages: Math.ceil(totalDocs / limit),
|
|
60
|
+
}),
|
|
61
|
+
{
|
|
62
|
+
status: 200,
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("Error fetching audit logs:", error);
|
|
68
|
+
return new Response(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
error: "Failed to fetch audit logs",
|
|
71
|
+
docs: [],
|
|
72
|
+
totalDocs: 0,
|
|
73
|
+
}),
|
|
74
|
+
{
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { RedisAuthAdapter } from "@kyro-cms/core";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
|
|
5
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
6
|
+
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "24h";
|
|
7
|
+
|
|
8
|
+
async function getAuthApi() {
|
|
9
|
+
return new RedisAuthAdapter({
|
|
10
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
11
|
+
tls: process.env.REDIS_TLS === "true",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
16
|
+
try {
|
|
17
|
+
const body = (await request.json()) as {
|
|
18
|
+
email?: string;
|
|
19
|
+
password?: string;
|
|
20
|
+
};
|
|
21
|
+
const { email, password } = body;
|
|
22
|
+
|
|
23
|
+
if (!email || !password) {
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({ error: "Email and password required" }),
|
|
26
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const adapter = await getAuthApi();
|
|
31
|
+
await adapter.connect();
|
|
32
|
+
|
|
33
|
+
const user = await adapter.findUserByEmail(email);
|
|
34
|
+
if (!user || !user.passwordHash) {
|
|
35
|
+
await adapter.disconnect();
|
|
36
|
+
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
|
|
37
|
+
status: 401,
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (user.locked) {
|
|
43
|
+
await adapter.disconnect();
|
|
44
|
+
return new Response(JSON.stringify({ error: "Account is locked" }), {
|
|
45
|
+
status: 403,
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const valid = await adapter.verifyPassword(password, user.passwordHash);
|
|
51
|
+
if (!valid) {
|
|
52
|
+
await adapter.recordFailedAttempt(user.id);
|
|
53
|
+
await adapter.disconnect();
|
|
54
|
+
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
|
|
55
|
+
status: 401,
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await adapter.resetAttempts(user.id);
|
|
61
|
+
|
|
62
|
+
const session = await adapter.createSession(user.id, {
|
|
63
|
+
ipAddress: request.headers.get("x-forwarded-for") || "unknown",
|
|
64
|
+
userAgent: request.headers.get("user-agent") || "",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const token = jwt.sign(
|
|
68
|
+
{
|
|
69
|
+
sub: user.id,
|
|
70
|
+
email: user.email,
|
|
71
|
+
role: user.role,
|
|
72
|
+
tenantId: user.tenantId,
|
|
73
|
+
},
|
|
74
|
+
JWT_SECRET,
|
|
75
|
+
{ expiresIn: JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
await adapter.disconnect();
|
|
79
|
+
|
|
80
|
+
const { passwordHash, ...safeUser } = user;
|
|
81
|
+
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
success: true,
|
|
85
|
+
user: safeUser,
|
|
86
|
+
token,
|
|
87
|
+
refreshToken: session.refreshToken,
|
|
88
|
+
}),
|
|
89
|
+
{
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("Login error:", error);
|
|
96
|
+
return new Response(JSON.stringify({ error: "Login failed" }), {
|
|
97
|
+
status: 500,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { RedisAuthAdapter } from "@kyro-cms/core";
|
|
3
|
+
|
|
4
|
+
async function getAuthApi() {
|
|
5
|
+
return new RedisAuthAdapter({
|
|
6
|
+
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
7
|
+
tls: process.env.REDIS_TLS === "true",
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
12
|
+
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
|
+
}
|
|
22
|
+
|
|
23
|
+
const adapter = await getAuthApi();
|
|
24
|
+
await adapter.connect();
|
|
25
|
+
|
|
26
|
+
const session = await adapter.findSessionByToken(refreshToken);
|
|
27
|
+
if (!session) {
|
|
28
|
+
await adapter.disconnect();
|
|
29
|
+
return new Response(JSON.stringify({ error: "Invalid refresh token" }), {
|
|
30
|
+
status: 401,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await adapter.disconnect();
|
|
36
|
+
|
|
37
|
+
return new Response(JSON.stringify({ success: true, session }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
});
|
|
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" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
|
|
4
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ request }) => {
|
|
7
|
+
const authHeader = request.headers.get("authorization");
|
|
8
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
9
|
+
|
|
10
|
+
if (!token) {
|
|
11
|
+
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
12
|
+
status: 401,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload;
|
|
19
|
+
return new Response(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
user: {
|
|
22
|
+
id: payload.sub,
|
|
23
|
+
email: (payload as any).email,
|
|
24
|
+
role: (payload as any).role,
|
|
25
|
+
tenantId: (payload as any).tenantId,
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
|
32
|
+
status: 401,
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|