@kyro-cms/admin 0.1.6 → 0.1.8

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.
Files changed (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
@@ -1,62 +1,39 @@
1
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
- }
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 success = url.searchParams.get("success");
9
+ const successParam = url.searchParams.get("success");
10
+ const resource = url.searchParams.get("resource") || "";
27
11
 
28
12
  try {
29
- const logs = await auditLogger.getLogs({
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
- 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);
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: paginatedLogs,
56
- totalDocs,
32
+ docs: logs,
33
+ totalDocs: total,
57
34
  page,
58
35
  limit,
59
- totalPages: Math.ceil(totalDocs / limit),
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: 200,
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 { SQLiteAuthAdapter } from "@kyro-cms/core";
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 JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "24h";
14
+ const REFRESH_SECRET =
15
+ process.env.REFRESH_SECRET ||
16
+ process.env.JWT_SECRET ||
17
+ "change-me-in-production";
7
18
 
8
- async function getAuthApi() {
9
- return new SQLiteAuthAdapter({
10
- path: process.env.KYRO_AUTH_DB_PATH || "./data/auth.db",
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
- const adapter = await getAuthApi();
30
- await adapter.connect();
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
- const user = await adapter.findUserByEmail(email);
33
- if (!user || !user.passwordHash) {
34
- await adapter.disconnect();
35
- return new Response(JSON.stringify({ error: "Invalid credentials" }), {
36
- status: 401,
37
- headers: { "Content-Type": "application/json" },
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 (user.locked) {
42
- await adapter.disconnect();
43
- return new Response(JSON.stringify({ error: "Account is locked" }), {
44
- status: 403,
45
- headers: { "Content-Type": "application/json" },
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 valid = await adapter.verifyPassword(password, user.passwordHash);
50
- if (!valid) {
51
- await adapter.disconnect();
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
- const session = await adapter.createSession(user.id, {
59
- ipAddress: request.headers.get("x-forwarded-for") || "unknown",
60
- userAgent: request.headers.get("user-agent") || "",
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.disconnect();
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 { passwordHash, ...safeUser } = user;
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: safeUser,
82
- token,
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: { "Content-Type": "application/json" },
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 { SQLiteAuthAdapter } from "@kyro-cms/core";
2
+ import { getAuthAdapter } from "../../../lib/db";
3
+ import { createAuditContext } from "@kyro-cms/core";
3
4
 
4
- const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
5
-
6
- async function getAuthApi() {
7
- return new SQLiteAuthAdapter({
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
- 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;
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
- if (token) {
26
- const adapter = await getAuthApi();
27
- await adapter.connect();
28
- await adapter.deleteSession(token);
29
- await adapter.disconnect();
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": "auth_token=; path=/; max-age=0",
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": "auth_token=; path=/; max-age=0",
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
+ };