@kyro-cms/admin 0.1.6 → 0.1.7
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 +149 -51
- package/package.json +53 -6
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +136 -27
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +1417 -661
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +3 -3
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +199 -57
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +786 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +191 -53
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +149 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +97 -0
- package/src/components/blocks/ArrayBlock.tsx +75 -0
- package/src/components/blocks/BlockEditModal.MARKER +12 -0
- package/src/components/blocks/BlockEditModal.tsx +774 -0
- package/src/components/blocks/ButtonBlock.tsx +165 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +66 -0
- package/src/components/blocks/ColumnsBlock.tsx +151 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +64 -0
- package/src/components/blocks/HeadingBlock.tsx +81 -0
- package/src/components/blocks/HeroBlock.tsx +157 -0
- package/src/components/blocks/ImageBlock.tsx +83 -0
- package/src/components/blocks/LinkBlock.tsx +71 -0
- package/src/components/blocks/ListBlock.tsx +39 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +279 -0
- package/src/components/blocks/VStackBlock.tsx +75 -0
- package/src/components/blocks/VideoBlock.tsx +45 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/CheckboxField.tsx +15 -9
- package/src/components/fields/CodeField.tsx +234 -0
- package/src/components/fields/DateField.tsx +38 -11
- package/src/components/fields/EditorClient.tsx +271 -0
- package/src/components/fields/FileField.tsx +390 -0
- package/src/components/fields/HybridContentField.tsx +109 -0
- package/src/components/fields/ImageField.tsx +429 -0
- package/src/components/fields/JSONField.tsx +361 -0
- package/src/components/fields/MarkdownField.tsx +282 -0
- package/src/components/fields/NumberField.tsx +42 -12
- package/src/components/fields/PortableTextField.tsx +143 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipField.tsx +231 -59
- package/src/components/fields/SelectField.tsx +25 -15
- package/src/components/fields/TextField.tsx +45 -14
- package/src/components/fields/extensions/blockComponents.tsx +237 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +13 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +2 -2
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +22 -6
- package/src/lib/dataStore.ts +132 -74
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +44 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +36 -0
- package/src/pages/api/[collection]/[id].ts +102 -159
- package/src/pages/api/[collection]/index.ts +151 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +553 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -1,62 +1,39 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
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
|
-
}
|
|
2
|
+
import { getAuthAdapter } from "../../../lib/db";
|
|
18
3
|
|
|
19
4
|
export const GET: APIRoute = async ({ url }) => {
|
|
20
|
-
await ensureConnection();
|
|
21
|
-
|
|
22
5
|
const page = parseInt(url.searchParams.get("page") || "1");
|
|
23
6
|
const limit = parseInt(url.searchParams.get("limit") || "25");
|
|
24
7
|
const action = url.searchParams.get("action") || "";
|
|
25
8
|
const userId = url.searchParams.get("userId") || "";
|
|
26
|
-
const
|
|
9
|
+
const successParam = url.searchParams.get("success");
|
|
10
|
+
const resource = url.searchParams.get("resource") || "";
|
|
27
11
|
|
|
28
12
|
try {
|
|
29
|
-
const
|
|
13
|
+
const adapter = await getAuthAdapter();
|
|
14
|
+
const offset = (page - 1) * limit;
|
|
15
|
+
|
|
16
|
+
const { logs, total } = await adapter.findAuditLogs({
|
|
17
|
+
limit,
|
|
18
|
+
offset,
|
|
30
19
|
action: action || undefined,
|
|
31
20
|
userId: userId || undefined,
|
|
21
|
+
resource: resource || undefined,
|
|
32
22
|
success:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
23
|
+
successParam === "true"
|
|
24
|
+
? true
|
|
25
|
+
: successParam === "false"
|
|
26
|
+
? false
|
|
27
|
+
: undefined,
|
|
28
|
+
} as Parameters<typeof adapter.findAuditLogs>[0]);
|
|
52
29
|
|
|
53
30
|
return new Response(
|
|
54
31
|
JSON.stringify({
|
|
55
|
-
docs:
|
|
56
|
-
totalDocs,
|
|
32
|
+
docs: logs,
|
|
33
|
+
totalDocs: total,
|
|
57
34
|
page,
|
|
58
35
|
limit,
|
|
59
|
-
totalPages: Math.ceil(
|
|
36
|
+
totalPages: Math.ceil(total / limit),
|
|
60
37
|
}),
|
|
61
38
|
{
|
|
62
39
|
status: 200,
|
|
@@ -72,7 +49,7 @@ export const GET: APIRoute = async ({ url }) => {
|
|
|
72
49
|
totalDocs: 0,
|
|
73
50
|
}),
|
|
74
51
|
{
|
|
75
|
-
status:
|
|
52
|
+
status: 500,
|
|
76
53
|
headers: { "Content-Type": "application/json" },
|
|
77
54
|
},
|
|
78
55
|
);
|
|
@@ -1,17 +1,84 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
2
|
+
import { getAuthAdapter } from "../../../lib/db";
|
|
3
|
+
import { createAuditContext } from "@kyro-cms/core";
|
|
4
|
+
import {
|
|
5
|
+
checkRateLimit,
|
|
6
|
+
recordFailedLogin,
|
|
7
|
+
getAccountLockStatus,
|
|
8
|
+
resetRateLimit,
|
|
9
|
+
} from "../../../lib/rate-limit";
|
|
3
10
|
import jwt from "jsonwebtoken";
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
4
12
|
|
|
5
13
|
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
6
|
-
const
|
|
14
|
+
const REFRESH_SECRET =
|
|
15
|
+
process.env.REFRESH_SECRET ||
|
|
16
|
+
process.env.JWT_SECRET ||
|
|
17
|
+
"change-me-in-production";
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
const ACCESS_TOKEN_EXPIRY = process.env.JWT_EXPIRES_IN || "24h";
|
|
20
|
+
const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || "7d";
|
|
21
|
+
|
|
22
|
+
function getClientIp(request: Request): string {
|
|
23
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
24
|
+
if (forwarded) return forwarded.split(",")[0].trim();
|
|
25
|
+
return request.headers.get("x-real-ip") || "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getExpirySeconds(expiry: string): number {
|
|
29
|
+
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
30
|
+
if (!match) return 86400;
|
|
31
|
+
const value = parseInt(match[1]);
|
|
32
|
+
const unit = match[2];
|
|
33
|
+
switch (unit) {
|
|
34
|
+
case "s":
|
|
35
|
+
return value;
|
|
36
|
+
case "m":
|
|
37
|
+
return value * 60;
|
|
38
|
+
case "h":
|
|
39
|
+
return value * 3600;
|
|
40
|
+
case "d":
|
|
41
|
+
return value * 86400;
|
|
42
|
+
default:
|
|
43
|
+
return 86400;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateTokens(user: { id: string; email: string; role: string }) {
|
|
48
|
+
const accessToken = jwt.sign(
|
|
49
|
+
{ sub: user.id, email: user.email, role: user.role, type: "access" },
|
|
50
|
+
JWT_SECRET,
|
|
51
|
+
{ expiresIn: ACCESS_TOKEN_EXPIRY as jwt.SignOptions["expiresIn"] },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const refreshToken = jwt.sign(
|
|
55
|
+
{
|
|
56
|
+
sub: user.id,
|
|
57
|
+
email: user.email,
|
|
58
|
+
role: user.role,
|
|
59
|
+
type: "refresh",
|
|
60
|
+
jti: randomBytes(16).toString("hex"),
|
|
61
|
+
},
|
|
62
|
+
REFRESH_SECRET,
|
|
63
|
+
{ expiresIn: REFRESH_TOKEN_EXPIRY as jwt.SignOptions["expiresIn"] },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return { accessToken, refreshToken };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createAuthCookies(token: string, refreshToken: string) {
|
|
70
|
+
const accessMaxAge = getExpirySeconds(ACCESS_TOKEN_EXPIRY);
|
|
71
|
+
const refreshMaxAge = getExpirySeconds(REFRESH_TOKEN_EXPIRY);
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
`auth_token=${token}; Path=/; Max-Age=${accessMaxAge}; HttpOnly; Secure; SameSite=Strict`,
|
|
75
|
+
`refresh_token=${refreshToken}; Path=/; Max-Age=${refreshMaxAge}; HttpOnly; Secure; SameSite=Strict`,
|
|
76
|
+
].join(", ");
|
|
12
77
|
}
|
|
13
78
|
|
|
14
79
|
export const POST: APIRoute = async ({ request }) => {
|
|
80
|
+
const clientIp = getClientIp(request);
|
|
81
|
+
|
|
15
82
|
try {
|
|
16
83
|
const body = (await request.json()) as {
|
|
17
84
|
email?: string;
|
|
@@ -26,65 +93,112 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
26
93
|
);
|
|
27
94
|
}
|
|
28
95
|
|
|
29
|
-
|
|
30
|
-
await
|
|
96
|
+
// Check rate limit by IP
|
|
97
|
+
const ipRateLimit = await checkRateLimit(clientIp, "login_ip");
|
|
98
|
+
if (!ipRateLimit.allowed) {
|
|
99
|
+
return new Response(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
error: "Too many login attempts. Please try again later.",
|
|
102
|
+
retryAfter: Math.ceil(
|
|
103
|
+
((ipRateLimit.lockedUntil?.getTime() ?? 0) - Date.now()) / 1000,
|
|
104
|
+
),
|
|
105
|
+
}),
|
|
106
|
+
{
|
|
107
|
+
status: 429,
|
|
108
|
+
headers: { "Content-Type": "application/json", "Retry-After": "900" },
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
}
|
|
31
112
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return new Response(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
113
|
+
// Check rate limit by email
|
|
114
|
+
const emailRateLimit = await checkRateLimit(email, "login_email");
|
|
115
|
+
if (!emailRateLimit.allowed) {
|
|
116
|
+
return new Response(
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
error: "Too many login attempts. Please try again later.",
|
|
119
|
+
retryAfter: Math.ceil(
|
|
120
|
+
((emailRateLimit.lockedUntil?.getTime() ?? 0) - Date.now()) / 1000,
|
|
121
|
+
),
|
|
122
|
+
}),
|
|
123
|
+
{
|
|
124
|
+
status: 429,
|
|
125
|
+
headers: { "Content-Type": "application/json", "Retry-After": "900" },
|
|
126
|
+
},
|
|
127
|
+
);
|
|
39
128
|
}
|
|
40
129
|
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
130
|
+
// Check if account is locked
|
|
131
|
+
const lockStatus = await getAccountLockStatus(email);
|
|
132
|
+
if (lockStatus.isLocked) {
|
|
133
|
+
return new Response(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
error:
|
|
136
|
+
"Account is temporarily locked due to too many failed attempts.",
|
|
137
|
+
retryAfter: Math.ceil(
|
|
138
|
+
((lockStatus.lockedUntil?.getTime() ?? 0) - Date.now()) / 1000,
|
|
139
|
+
),
|
|
140
|
+
}),
|
|
141
|
+
{ status: 423, headers: { "Content-Type": "application/json" } },
|
|
142
|
+
);
|
|
47
143
|
}
|
|
48
144
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
145
|
+
const adapter = await getAuthAdapter();
|
|
146
|
+
const user = await adapter.verifyPassword(email, password);
|
|
147
|
+
const context = createAuditContext(request);
|
|
148
|
+
|
|
149
|
+
if (!user) {
|
|
150
|
+
// Record failed attempt
|
|
151
|
+
await recordFailedLogin(email);
|
|
152
|
+
await adapter.createAuditLog({
|
|
153
|
+
action: "login_failed",
|
|
154
|
+
userEmail: email,
|
|
155
|
+
resource: "auth",
|
|
156
|
+
success: false,
|
|
157
|
+
error: "Invalid credentials",
|
|
158
|
+
metadata: {
|
|
159
|
+
reason: "invalid_credentials",
|
|
160
|
+
attemptTime: new Date().toISOString(),
|
|
161
|
+
failedAttempts: lockStatus.failedAttempts + 1,
|
|
162
|
+
},
|
|
163
|
+
...context,
|
|
164
|
+
});
|
|
165
|
+
|
|
52
166
|
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
|
|
53
167
|
status: 401,
|
|
54
168
|
headers: { "Content-Type": "application/json" },
|
|
55
169
|
});
|
|
56
170
|
}
|
|
57
171
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const token = jwt.sign(
|
|
64
|
-
{
|
|
65
|
-
sub: user.id,
|
|
66
|
-
email: user.email,
|
|
67
|
-
role: user.role,
|
|
68
|
-
tenantId: user.tenantId,
|
|
69
|
-
},
|
|
70
|
-
JWT_SECRET,
|
|
71
|
-
{ expiresIn: JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
|
72
|
-
);
|
|
172
|
+
// Reset rate limits on successful login
|
|
173
|
+
await resetRateLimit(clientIp, "login_ip");
|
|
174
|
+
await resetRateLimit(email, "login_email");
|
|
73
175
|
|
|
74
|
-
await adapter.
|
|
176
|
+
await adapter.createAuditLog({
|
|
177
|
+
action: "login",
|
|
178
|
+
userId: user.id,
|
|
179
|
+
userEmail: user.email,
|
|
180
|
+
role: user.role,
|
|
181
|
+
resource: "auth",
|
|
182
|
+
success: true,
|
|
183
|
+
metadata: { method: "password" },
|
|
184
|
+
...context,
|
|
185
|
+
});
|
|
75
186
|
|
|
76
|
-
const {
|
|
187
|
+
const { accessToken, refreshToken } = generateTokens(user);
|
|
188
|
+
const expiresIn = getExpirySeconds(ACCESS_TOKEN_EXPIRY);
|
|
77
189
|
|
|
78
190
|
return new Response(
|
|
79
191
|
JSON.stringify({
|
|
80
192
|
success: true,
|
|
81
|
-
user:
|
|
82
|
-
|
|
83
|
-
refreshToken: session.refreshToken,
|
|
193
|
+
user: { id: user.id, email: user.email, role: user.role },
|
|
194
|
+
expiresIn,
|
|
84
195
|
}),
|
|
85
196
|
{
|
|
86
197
|
status: 200,
|
|
87
|
-
headers: {
|
|
198
|
+
headers: {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
"Set-Cookie": createAuthCookies(accessToken, refreshToken),
|
|
201
|
+
},
|
|
88
202
|
},
|
|
89
203
|
);
|
|
90
204
|
} catch (error) {
|
|
@@ -1,47 +1,65 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
2
|
+
import { getAuthAdapter } from "../../../lib/db";
|
|
3
|
+
import { createAuditContext } from "@kyro-cms/core";
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
|
|
9
|
-
});
|
|
10
|
-
}
|
|
5
|
+
const CLEAR_COOKIES = [
|
|
6
|
+
"auth_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
|
|
7
|
+
"refresh_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
|
|
8
|
+
].join(", ");
|
|
11
9
|
|
|
12
10
|
export const POST: APIRoute = async ({ request }) => {
|
|
13
11
|
try {
|
|
14
|
-
// Check Authorization header or cookie for token
|
|
15
|
-
let token: string | null = null;
|
|
16
12
|
const authHeader = request.headers.get("authorization");
|
|
13
|
+
let userId: string | null = null;
|
|
14
|
+
|
|
17
15
|
if (authHeader?.startsWith("Bearer ")) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const { getAuthAdapter } = await import("../../../lib/db");
|
|
17
|
+
const adapter = await getAuthAdapter();
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { default: jwt } = await import("jsonwebtoken");
|
|
21
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
22
|
+
|
|
23
|
+
const token = authHeader.slice(7);
|
|
24
|
+
const decoded = jwt.verify(token, JWT_SECRET) as { sub: string };
|
|
25
|
+
userId = decoded.sub;
|
|
26
|
+
|
|
27
|
+
if (userId) {
|
|
28
|
+
const user = await adapter.findUserById(userId);
|
|
29
|
+
const context = createAuditContext(request);
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
if (user) {
|
|
32
|
+
await adapter.createAuditLog({
|
|
33
|
+
action: "logout",
|
|
34
|
+
userId: user.id,
|
|
35
|
+
userEmail: user.email,
|
|
36
|
+
role: user.role,
|
|
37
|
+
resource: "auth",
|
|
38
|
+
success: true,
|
|
39
|
+
metadata: { method: "jwt" },
|
|
40
|
+
...context,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Invalid or expired token - just clear cookies
|
|
46
|
+
}
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
return new Response(JSON.stringify({ success: true }), {
|
|
33
50
|
status: 200,
|
|
34
51
|
headers: {
|
|
35
52
|
"Content-Type": "application/json",
|
|
36
|
-
"Set-Cookie":
|
|
53
|
+
"Set-Cookie": CLEAR_COOKIES,
|
|
37
54
|
},
|
|
38
55
|
});
|
|
39
|
-
} catch {
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("Logout error:", error);
|
|
40
58
|
return new Response(JSON.stringify({ success: true }), {
|
|
41
59
|
status: 200,
|
|
42
60
|
headers: {
|
|
43
61
|
"Content-Type": "application/json",
|
|
44
|
-
"Set-Cookie":
|
|
62
|
+
"Set-Cookie": CLEAR_COOKIES,
|
|
45
63
|
},
|
|
46
64
|
});
|
|
47
65
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
|
|
5
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
|
|
6
|
+
const REFRESH_SECRET =
|
|
7
|
+
process.env.REFRESH_SECRET ||
|
|
8
|
+
process.env.JWT_SECRET ||
|
|
9
|
+
"change-me-in-production";
|
|
10
|
+
|
|
11
|
+
const ACCESS_TOKEN_EXPIRY = process.env.JWT_EXPIRES_IN || "24h";
|
|
12
|
+
const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || "7d";
|
|
13
|
+
|
|
14
|
+
function getExpirySeconds(expiry: string): number {
|
|
15
|
+
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
16
|
+
if (!match) return 86400;
|
|
17
|
+
const value = parseInt(match[1]);
|
|
18
|
+
const unit = match[2];
|
|
19
|
+
switch (unit) {
|
|
20
|
+
case "s":
|
|
21
|
+
return value;
|
|
22
|
+
case "m":
|
|
23
|
+
return value * 60;
|
|
24
|
+
case "h":
|
|
25
|
+
return value * 3600;
|
|
26
|
+
case "d":
|
|
27
|
+
return value * 86400;
|
|
28
|
+
default:
|
|
29
|
+
return 86400;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generateTokens(user: { id: string; email: string; role: string }) {
|
|
34
|
+
const accessToken = jwt.sign(
|
|
35
|
+
{ sub: user.id, email: user.email, role: user.role, type: "access" },
|
|
36
|
+
JWT_SECRET,
|
|
37
|
+
{ expiresIn: ACCESS_TOKEN_EXPIRY as jwt.SignOptions["expiresIn"] },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const refreshToken = jwt.sign(
|
|
41
|
+
{
|
|
42
|
+
sub: user.id,
|
|
43
|
+
email: user.email,
|
|
44
|
+
role: user.role,
|
|
45
|
+
type: "refresh",
|
|
46
|
+
jti: randomBytes(16).toString("hex"),
|
|
47
|
+
},
|
|
48
|
+
REFRESH_SECRET,
|
|
49
|
+
{ expiresIn: REFRESH_TOKEN_EXPIRY as jwt.SignOptions["expiresIn"] },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return { accessToken, refreshToken };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createAuthCookies(token: string, refreshToken: string) {
|
|
56
|
+
const accessMaxAge = getExpirySeconds(ACCESS_TOKEN_EXPIRY);
|
|
57
|
+
const refreshMaxAge = getExpirySeconds(REFRESH_TOKEN_EXPIRY);
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
`auth_token=${token}; Path=/; Max-Age=${accessMaxAge}; HttpOnly; Secure; SameSite=Strict`,
|
|
61
|
+
`refresh_token=${refreshToken}; Path=/; Max-Age=${refreshMaxAge}; HttpOnly; Secure; SameSite=Strict`,
|
|
62
|
+
].join(", ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
66
|
+
try {
|
|
67
|
+
const refreshToken = request.headers.get("x-refresh-token");
|
|
68
|
+
|
|
69
|
+
if (!refreshToken) {
|
|
70
|
+
return new Response(JSON.stringify({ error: "Refresh token required" }), {
|
|
71
|
+
status: 401,
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const decoded = jwt.verify(refreshToken, REFRESH_SECRET) as {
|
|
77
|
+
sub: string;
|
|
78
|
+
email: string;
|
|
79
|
+
role: string;
|
|
80
|
+
type: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (decoded.type !== "refresh") {
|
|
84
|
+
return new Response(JSON.stringify({ error: "Invalid token type" }), {
|
|
85
|
+
status: 401,
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const user = {
|
|
91
|
+
id: decoded.sub,
|
|
92
|
+
email: decoded.email,
|
|
93
|
+
role: decoded.role,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
|
|
97
|
+
generateTokens(user);
|
|
98
|
+
|
|
99
|
+
return new Response(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
success: true,
|
|
102
|
+
expiresIn: getExpirySeconds(ACCESS_TOKEN_EXPIRY),
|
|
103
|
+
}),
|
|
104
|
+
{
|
|
105
|
+
status: 200,
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Set-Cookie": createAuthCookies(newAccessToken, newRefreshToken),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("Refresh error:", error);
|
|
114
|
+
return new Response(
|
|
115
|
+
JSON.stringify({ error: "Invalid or expired refresh token" }),
|
|
116
|
+
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
};
|