@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
@@ -0,0 +1,353 @@
1
+ import { create } from "zustand";
2
+
3
+ export const en = {
4
+ common: {
5
+ save: "Save",
6
+ cancel: "Cancel",
7
+ delete: "Delete",
8
+ edit: "Edit",
9
+ create: "Create",
10
+ add: "Add",
11
+ update: "Update",
12
+ confirm: "Confirm",
13
+ clear: "Clear",
14
+ close: "Close",
15
+ back: "Back",
16
+ next: "Next",
17
+ submit: "Submit",
18
+ remove: "Remove",
19
+ loading: "Loading...",
20
+ },
21
+
22
+ status: {
23
+ draft: "Draft",
24
+ published: "Published",
25
+ error: "Error",
26
+ success: "Success",
27
+ failed: "Failed",
28
+ archived: "Archived",
29
+ },
30
+
31
+ form: {
32
+ email: "Email",
33
+ password: "Password",
34
+ confirmPassword: "Confirm Password",
35
+ name: "Name",
36
+ label: "Label",
37
+ title: "Title",
38
+ description: "Description",
39
+ role: "Role",
40
+ },
41
+
42
+ placeholder: {
43
+ search: "Search...",
44
+ searchByNameOrEmail: "Search by name or email...",
45
+ searchFiles: "Search files...",
46
+ searchMedia: "Search media...",
47
+ searchTypes: "Search types...",
48
+ enterHeading: "Enter heading text...",
49
+ enterParagraph: "Enter paragraph text...",
50
+ enterMarkdown: "Enter markdown content...",
51
+ linkText: "Link text...",
52
+ url: "https://...",
53
+ value: "Value...",
54
+ videoUrl: "MP4 URL, YouTube, or Vimeo link...",
55
+ jsonPlaceholder: '{"key": "value"}',
56
+ folderName: "Folder name",
57
+ apiKey: "API Key",
58
+ },
59
+
60
+ tooltip: {
61
+ moveUp: "Move up",
62
+ moveDown: "Move down",
63
+ remove: "Remove",
64
+ edit: "Edit",
65
+ delete: "Delete",
66
+ livePreview: "Live Preview",
67
+ toggleSidebar: "Toggle Sidebar",
68
+ fullscreen: "Fullscreen",
69
+ exitFullscreen: "Exit fullscreen",
70
+ undo: "Undo",
71
+ redo: "Redo",
72
+ bold: "Bold",
73
+ italic: "Italic",
74
+ underline: "Underline",
75
+ strikethrough: "Strikethrough",
76
+ code: "Code",
77
+ link: "Link",
78
+ bulletList: "Bullet List",
79
+ numberedList: "Numbered List",
80
+ generateNewKey: "Generate new key",
81
+ copyToClipboard: "Copy to clipboard",
82
+ },
83
+
84
+ media: {
85
+ all: "All",
86
+ images: "Images",
87
+ videos: "Videos",
88
+ audio: "Audio",
89
+ documents: "Documents",
90
+ archives: "Archives",
91
+ noMediaFound: "No media files found",
92
+ deleteMedia: "Delete Media",
93
+ createFolder: "Create New Folder",
94
+ deleteFolder: "Delete Folder",
95
+ deleteSelected: "Delete Selected",
96
+ downloadCollection: "Download Collection",
97
+ editMetadata: "Edit metadata",
98
+ download: "Download",
99
+ },
100
+
101
+ user: {
102
+ teamManagement: "Team Management",
103
+ inviteMember: "Invite Member",
104
+ allUsers: "All Users",
105
+ admins: "Admins",
106
+ restricted: "Restricted",
107
+ },
108
+
109
+ auth: {
110
+ signIn: "Sign In",
111
+ createAccount: "Create Account",
112
+ signingIn: "Signing in...",
113
+ creatingAccount: "Creating account...",
114
+ dontHaveAccount: "Don't have an account?",
115
+ signUp: "Sign up",
116
+ alreadyHaveAccount: "Already have an account?",
117
+ },
118
+
119
+ confirm: {
120
+ deleteEntry: "Delete Entry",
121
+ deleteConfirm: "Delete {item}?",
122
+ actionCannotBeUndone: "This action cannot be undone.",
123
+ deleteDocuments: "Delete Documents",
124
+ },
125
+
126
+ audit: {
127
+ eventId: "Event ID",
128
+ timestamp: "Timestamp",
129
+ userEmail: "User Email",
130
+ userId: "User ID",
131
+ role: "Role",
132
+ resource: "Resource",
133
+ resourceId: "Resource ID",
134
+ ipAddress: "IP Address",
135
+ },
136
+
137
+ collection: {
138
+ pages: "Pages",
139
+ posts: "Posts",
140
+ categories: "Categories",
141
+ media: "Media",
142
+ settings: "Settings",
143
+ navigation: "Navigation",
144
+ users: "Users",
145
+ },
146
+
147
+ item: {
148
+ item: "Item",
149
+ itemCount: "Item {n}",
150
+ items: "Items",
151
+ noItems: 'No items. Click "Add Item" to create one.',
152
+ },
153
+
154
+ empty: {
155
+ noResults: "No results found",
156
+ loading: "Loading...",
157
+ noData: "No data available",
158
+ },
159
+
160
+ validation: {
161
+ required: "This field is required",
162
+ email: "Please enter a valid email address",
163
+ url: "Please enter a valid URL",
164
+ minLength: "Minimum {min} characters required",
165
+ maxLength: "Maximum {max} characters allowed",
166
+ number: "Please enter a valid number",
167
+ integer: "Please enter a whole number",
168
+ range: "Please enter a value between {min} and {max}",
169
+ json: "Please enter valid JSON",
170
+ hexColor: "Please enter a valid hex color",
171
+ phone: "Please enter a valid phone number",
172
+ postalCode: "Please enter a valid postal code",
173
+ matches: "Values do not match",
174
+ pattern: "Invalid format",
175
+ },
176
+
177
+ errors: {
178
+ generic: "Something went wrong. Please try again.",
179
+ network: "Network error. Please check your connection.",
180
+ unauthorized: "You are not authorized to perform this action.",
181
+ notFound: "The requested resource was not found.",
182
+ serverError: "Server error. Please try again later.",
183
+ },
184
+
185
+ actions: {
186
+ save: "Save",
187
+ cancel: "Cancel",
188
+ delete: "Delete",
189
+ edit: "Edit",
190
+ create: "Create",
191
+ add: "Add",
192
+ update: "Update",
193
+ confirm: "Confirm",
194
+ clear: "Clear",
195
+ close: "Close",
196
+ back: "Back",
197
+ next: "Next",
198
+ submit: "Submit",
199
+ remove: "Remove",
200
+ search: "Search",
201
+ filter: "Filter",
202
+ sort: "Sort",
203
+ refresh: "Refresh",
204
+ reload: "Reload",
205
+ export: "Export",
206
+ import: "Import",
207
+ upload: "Upload",
208
+ download: "Download",
209
+ copy: "Copy",
210
+ select: "Select",
211
+ selectAll: "Select All",
212
+ deselectAll: "Deselect All",
213
+ },
214
+
215
+ pagination: {
216
+ page: "Page",
217
+ of: "of",
218
+ first: "First",
219
+ last: "Last",
220
+ previous: "Previous",
221
+ next: "Next",
222
+ showing: "Showing",
223
+ to: "to",
224
+ from: "from",
225
+ results: "results",
226
+ perPage: "Per page",
227
+ },
228
+
229
+ filters: {
230
+ all: "All",
231
+ active: "Active",
232
+ inactive: "Inactive",
233
+ published: "Published",
234
+ draft: "Draft",
235
+ archived: "Archived",
236
+ search: "Search",
237
+ dateRange: "Date Range",
238
+ clearFilters: "Clear Filters",
239
+ applyFilters: "Apply Filters",
240
+ },
241
+
242
+ sort: {
243
+ ascending: "Ascending",
244
+ descending: "Descending",
245
+ sortBy: "Sort by",
246
+ },
247
+
248
+ table: {
249
+ noColumns: "No columns selected",
250
+ toggleColumns: "Toggle Columns",
251
+ rowsPerPage: "Rows per page",
252
+ },
253
+
254
+ blocks: {
255
+ addBlock: "Add Block",
256
+ removeBlock: "Remove Block",
257
+ moveUp: "Move Up",
258
+ moveDown: "Move Down",
259
+ },
260
+
261
+ richText: {
262
+ bold: "Bold",
263
+ italic: "Italic",
264
+ underline: "Underline",
265
+ strikethrough: "Strikethrough",
266
+ bulletList: "Bullet List",
267
+ numberedList: "Numbered List",
268
+ heading: "Heading",
269
+ link: "Link",
270
+ code: "Code",
271
+ },
272
+
273
+ upload: {
274
+ dragDrop: "Drag and drop files here",
275
+ orBrowse: "or browse",
276
+ uploading: "Uploading...",
277
+ uploadComplete: "Upload complete",
278
+ uploadFailed: "Upload failed",
279
+ fileTooLarge: "File is too large",
280
+ invalidType: "Invalid file type",
281
+ },
282
+
283
+ version: {
284
+ versionHistory: "Version History",
285
+ currentVersion: "Current Version",
286
+ restoreVersion: "Restore Version",
287
+ previewVersion: "Preview Version",
288
+ compareVersion: "Compare Versions",
289
+ autoSaved: "Auto-saved",
290
+ manuallySaved: "Manually saved",
291
+ },
292
+ };
293
+
294
+ type Translations = typeof en;
295
+
296
+ interface I18nState {
297
+ locale: string;
298
+ translations: Translations;
299
+ setLocale: (locale: string) => void;
300
+ }
301
+
302
+ export const useI18n = create<I18nState>((set) => ({
303
+ locale: "en",
304
+ translations: en,
305
+ setLocale: (locale) => set({ locale }),
306
+ }));
307
+
308
+ export function getT(
309
+ key: string,
310
+ replacements?: Record<string, string>,
311
+ ): string {
312
+ const { translations } = useI18n.getState();
313
+ const keys = key.split(".");
314
+ let result: any = translations;
315
+
316
+ for (const k of keys) {
317
+ result = result?.[k];
318
+ if (result === undefined) return key;
319
+ }
320
+
321
+ if (typeof result !== "string") return key;
322
+
323
+ if (replacements) {
324
+ return Object.entries(replacements).reduce(
325
+ (str, [k, v]) => str.replace(new RegExp(`\\{${k}}`, "g"), v),
326
+ result,
327
+ );
328
+ }
329
+
330
+ return result;
331
+ }
332
+
333
+ export function useTranslation() {
334
+ const translations = useI18n((state) => state.translations);
335
+ return {
336
+ t: (key: string, replacements?: Record<string, string>) => {
337
+ const keys = key.split(".");
338
+ let result: any = translations;
339
+ for (const k of keys) {
340
+ result = result?.[k];
341
+ if (result === undefined) return key;
342
+ }
343
+ if (typeof result !== "string") return key;
344
+ if (replacements) {
345
+ return Object.entries(replacements).reduce(
346
+ (str, [k, v]) => str.replace(new RegExp(`\\{${k}}`, "g"), v),
347
+ result,
348
+ );
349
+ }
350
+ return result;
351
+ },
352
+ };
353
+ }
@@ -0,0 +1,267 @@
1
+ import Database from "better-sqlite3";
2
+ import { randomBytes } from "crypto";
3
+
4
+ const DB_PATH =
5
+ process.env.KYRO_AUTH_DB_PATH || process.env.KYRO_DB_PATH || "./data/auth.db";
6
+
7
+ interface RateLimitConfig {
8
+ maxAttempts: number;
9
+ windowMs: number;
10
+ lockoutMs: number;
11
+ }
12
+
13
+ interface RateLimitResult {
14
+ allowed: boolean;
15
+ remaining: number;
16
+ resetAt: Date | null;
17
+ lockedUntil: Date | null;
18
+ }
19
+
20
+ const DEFAULT_CONFIG: RateLimitConfig = {
21
+ maxAttempts: 5,
22
+ windowMs: 15 * 60 * 1000, // 15 minutes
23
+ lockoutMs: 15 * 60 * 1000, // 15 minutes lockout
24
+ };
25
+
26
+ function getDb() {
27
+ return new Database(DB_PATH);
28
+ }
29
+
30
+ export async function checkRateLimit(
31
+ identifier: string,
32
+ action: string = "login",
33
+ config: RateLimitConfig = DEFAULT_CONFIG,
34
+ ): Promise<RateLimitResult> {
35
+ const db = getDb();
36
+ const now = new Date();
37
+
38
+ try {
39
+ // Clean up expired records first
40
+ db.prepare(
41
+ `
42
+ DELETE FROM rate_limits
43
+ WHERE expires_at IS NOT NULL AND expires_at < ?
44
+ `,
45
+ ).run(now.toISOString());
46
+
47
+ // Get existing record
48
+ const existing = db
49
+ .prepare(
50
+ `
51
+ SELECT * FROM rate_limits
52
+ WHERE identifier = ? AND action = ?
53
+ `,
54
+ )
55
+ .get(identifier, action) as any;
56
+
57
+ if (!existing) {
58
+ // First attempt - create record
59
+ const id = randomBytes(16).toString("hex");
60
+ db.prepare(
61
+ `
62
+ INSERT INTO rate_limits (id, identifier, action, attempts, first_attempt, last_attempt, created_at)
63
+ VALUES (?, ?, ?, 1, ?, ?, ?)
64
+ `,
65
+ ).run(
66
+ id,
67
+ identifier,
68
+ action,
69
+ now.toISOString(),
70
+ now.toISOString(),
71
+ now.toISOString(),
72
+ );
73
+
74
+ return {
75
+ allowed: true,
76
+ remaining: config.maxAttempts - 1,
77
+ resetAt: new Date(now.getTime() + config.windowMs),
78
+ lockedUntil: null,
79
+ };
80
+ }
81
+
82
+ // Check if currently locked
83
+ if (existing.expires_at && new Date(existing.expires_at) > now) {
84
+ return {
85
+ allowed: false,
86
+ remaining: 0,
87
+ resetAt: null,
88
+ lockedUntil: new Date(existing.expires_at),
89
+ };
90
+ }
91
+
92
+ // Check if within window
93
+ const firstAttempt = existing.first_attempt
94
+ ? new Date(existing.first_attempt)
95
+ : now;
96
+ const windowEnd = new Date(firstAttempt.getTime() + config.windowMs);
97
+
98
+ if (now > windowEnd) {
99
+ // Window expired - reset attempts
100
+ db.prepare(
101
+ `
102
+ UPDATE rate_limits
103
+ SET attempts = 1, first_attempt = ?, last_attempt = ?, expires_at = NULL
104
+ WHERE identifier = ? AND action = ?
105
+ `,
106
+ ).run(now.toISOString(), now.toISOString(), identifier, action);
107
+
108
+ return {
109
+ allowed: true,
110
+ remaining: config.maxAttempts - 1,
111
+ resetAt: new Date(now.getTime() + config.windowMs),
112
+ lockedUntil: null,
113
+ };
114
+ }
115
+
116
+ // Within window - check attempts
117
+ const attempts = existing.attempts || 0;
118
+ const remaining = config.maxAttempts - attempts;
119
+
120
+ if (attempts >= config.maxAttempts) {
121
+ // Lock out the user
122
+ const lockUntil = new Date(now.getTime() + config.lockoutMs);
123
+ db.prepare(
124
+ `
125
+ UPDATE rate_limits
126
+ SET last_attempt = ?, expires_at = ?
127
+ WHERE identifier = ? AND action = ?
128
+ `,
129
+ ).run(now.toISOString(), lockUntil.toISOString(), identifier, action);
130
+
131
+ return {
132
+ allowed: false,
133
+ remaining: 0,
134
+ resetAt: windowEnd,
135
+ lockedUntil: lockUntil,
136
+ };
137
+ }
138
+
139
+ // Increment attempts
140
+ db.prepare(
141
+ `
142
+ UPDATE rate_limits
143
+ SET attempts = attempts + 1, last_attempt = ?
144
+ WHERE identifier = ? AND action = ?
145
+ `,
146
+ ).run(now.toISOString(), identifier, action);
147
+
148
+ return {
149
+ allowed: true,
150
+ remaining: remaining - 1,
151
+ resetAt: windowEnd,
152
+ lockedUntil: null,
153
+ };
154
+ } finally {
155
+ db.close();
156
+ }
157
+ }
158
+
159
+ export async function resetRateLimit(
160
+ identifier: string,
161
+ action: string = "login",
162
+ ): Promise<void> {
163
+ const db = getDb();
164
+
165
+ try {
166
+ db.prepare(
167
+ `
168
+ DELETE FROM rate_limits WHERE identifier = ? AND action = ?
169
+ `,
170
+ ).run(identifier, action);
171
+ } finally {
172
+ db.close();
173
+ }
174
+ }
175
+
176
+ export async function getAccountLockStatus(email: string): Promise<{
177
+ isLocked: boolean;
178
+ lockedUntil: Date | null;
179
+ failedAttempts: number;
180
+ }> {
181
+ const db = getDb();
182
+
183
+ try {
184
+ const user = db
185
+ .prepare(
186
+ `
187
+ SELECT locked, locked_until, failed_login_attempts
188
+ FROM users
189
+ WHERE LOWER(email) = LOWER(?)
190
+ `,
191
+ )
192
+ .get(email) as any;
193
+
194
+ if (!user) {
195
+ return { isLocked: false, lockedUntil: null, failedAttempts: 0 };
196
+ }
197
+
198
+ const now = new Date();
199
+ const lockedUntil = user.locked_until ? new Date(user.locked_until) : null;
200
+ const isLocked = user.locked === 1 && (!lockedUntil || lockedUntil > now);
201
+
202
+ return {
203
+ isLocked,
204
+ lockedUntil: isLocked ? lockedUntil : null,
205
+ failedAttempts: user.failed_login_attempts || 0,
206
+ };
207
+ } finally {
208
+ db.close();
209
+ }
210
+ }
211
+
212
+ export async function recordFailedLogin(email: string): Promise<void> {
213
+ const db = getDb();
214
+
215
+ try {
216
+ const now = new Date();
217
+ const user = db
218
+ .prepare(
219
+ `
220
+ SELECT id FROM users WHERE LOWER(email) = LOWER(?)
221
+ `,
222
+ )
223
+ .get(email) as any;
224
+
225
+ if (!user) return;
226
+
227
+ // Increment failed attempts
228
+ db.prepare(
229
+ `
230
+ UPDATE users
231
+ SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1,
232
+ last_login = ?,
233
+ locked = CASE
234
+ WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN 1
235
+ ELSE 0
236
+ END,
237
+ locked_until = CASE
238
+ WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN ?
239
+ ELSE NULL
240
+ END
241
+ WHERE id = ?
242
+ `,
243
+ ).run(
244
+ now.toISOString(),
245
+ new Date(now.getTime() + 15 * 60 * 1000).toISOString(),
246
+ user.id,
247
+ );
248
+ } finally {
249
+ db.close();
250
+ }
251
+ }
252
+
253
+ export async function unlockAccount(email: string): Promise<void> {
254
+ const db = getDb();
255
+
256
+ try {
257
+ db.prepare(
258
+ `
259
+ UPDATE users
260
+ SET locked = 0, locked_until = NULL, failed_login_attempts = 0
261
+ WHERE LOWER(email) = LOWER(?)
262
+ `,
263
+ ).run(email);
264
+ } finally {
265
+ db.close();
266
+ }
267
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Convert text to a URL-friendly slug.
3
+ */
4
+ export function slugifyText(text: string): string {
5
+ if (!text) return "";
6
+ return text
7
+ .toString()
8
+ .toLowerCase()
9
+ .trim()
10
+ .replace(/\s+/g, "-")
11
+ .replace(/[^\w-]+/g, "")
12
+ .replace(/--+/g, "-")
13
+ .replace(/^-+/, "")
14
+ .replace(/-+$/, "");
15
+ }