hazo_auth 0.1.0

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 (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/components.json +22 -0
  4. package/hazo_auth_config.example.ini +414 -0
  5. package/hazo_notify_config.example.ini +159 -0
  6. package/instrumentation.ts +32 -0
  7. package/migrations/001_add_token_type_to_refresh_tokens.sql +14 -0
  8. package/migrations/002_add_name_to_hazo_users.sql +7 -0
  9. package/next.config.mjs +55 -0
  10. package/package.json +114 -0
  11. package/postcss.config.mjs +8 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/scripts/apply_migration.ts +118 -0
  18. package/src/app/api/auth/change_password/route.ts +109 -0
  19. package/src/app/api/auth/forgot_password/route.ts +107 -0
  20. package/src/app/api/auth/library_photos/route.ts +70 -0
  21. package/src/app/api/auth/login/route.ts +155 -0
  22. package/src/app/api/auth/logout/route.ts +62 -0
  23. package/src/app/api/auth/me/route.ts +47 -0
  24. package/src/app/api/auth/profile_picture/[filename]/route.ts +67 -0
  25. package/src/app/api/auth/register/route.ts +106 -0
  26. package/src/app/api/auth/remove_profile_picture/route.ts +86 -0
  27. package/src/app/api/auth/resend_verification/route.ts +107 -0
  28. package/src/app/api/auth/reset_password/route.ts +107 -0
  29. package/src/app/api/auth/update_user/route.ts +126 -0
  30. package/src/app/api/auth/upload_profile_picture/route.ts +268 -0
  31. package/src/app/api/auth/validate_reset_token/route.ts +80 -0
  32. package/src/app/api/auth/verify_email/route.ts +85 -0
  33. package/src/app/api/migrations/apply/route.ts +91 -0
  34. package/src/app/favicon.ico +0 -0
  35. package/src/app/fonts/GeistMonoVF.woff +0 -0
  36. package/src/app/fonts/GeistVF.woff +0 -0
  37. package/src/app/forgot_password/forgot_password_page_client.tsx +60 -0
  38. package/src/app/forgot_password/page.tsx +24 -0
  39. package/src/app/globals.css +89 -0
  40. package/src/app/hazo_connect/api/sqlite/data/route.ts +197 -0
  41. package/src/app/hazo_connect/api/sqlite/schema/route.ts +35 -0
  42. package/src/app/hazo_connect/api/sqlite/tables/route.ts +26 -0
  43. package/src/app/hazo_connect/sqlite_admin/page.tsx +51 -0
  44. package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +947 -0
  45. package/src/app/layout.tsx +43 -0
  46. package/src/app/login/login_page_client.tsx +71 -0
  47. package/src/app/login/page.tsx +26 -0
  48. package/src/app/my_settings/my_settings_page_client.tsx +120 -0
  49. package/src/app/my_settings/page.tsx +40 -0
  50. package/src/app/page.tsx +170 -0
  51. package/src/app/register/page.tsx +26 -0
  52. package/src/app/register/register_page_client.tsx +72 -0
  53. package/src/app/reset_password/page.tsx +29 -0
  54. package/src/app/reset_password/reset_password_page_client.tsx +81 -0
  55. package/src/app/verify_email/page.tsx +24 -0
  56. package/src/app/verify_email/verify_email_page_client.tsx +60 -0
  57. package/src/components/layouts/email_verification/config/email_verification_field_config.ts +86 -0
  58. package/src/components/layouts/email_verification/hooks/use_email_verification.ts +291 -0
  59. package/src/components/layouts/email_verification/index.tsx +297 -0
  60. package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +58 -0
  61. package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +179 -0
  62. package/src/components/layouts/forgot_password/index.tsx +168 -0
  63. package/src/components/layouts/login/config/login_field_config.ts +67 -0
  64. package/src/components/layouts/login/hooks/use_login_form.ts +281 -0
  65. package/src/components/layouts/login/index.tsx +224 -0
  66. package/src/components/layouts/my_settings/components/editable_field.tsx +177 -0
  67. package/src/components/layouts/my_settings/components/password_change_dialog.tsx +301 -0
  68. package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +385 -0
  69. package/src/components/layouts/my_settings/components/profile_picture_display.tsx +66 -0
  70. package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +143 -0
  71. package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +282 -0
  72. package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +341 -0
  73. package/src/components/layouts/my_settings/config/my_settings_field_config.ts +61 -0
  74. package/src/components/layouts/my_settings/hooks/use_my_settings.ts +458 -0
  75. package/src/components/layouts/my_settings/index.tsx +351 -0
  76. package/src/components/layouts/register/config/register_field_config.ts +101 -0
  77. package/src/components/layouts/register/hooks/use_register_form.ts +272 -0
  78. package/src/components/layouts/register/index.tsx +208 -0
  79. package/src/components/layouts/reset_password/config/reset_password_field_config.ts +86 -0
  80. package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +276 -0
  81. package/src/components/layouts/reset_password/index.tsx +294 -0
  82. package/src/components/layouts/shared/components/already_logged_in_guard.tsx +95 -0
  83. package/src/components/layouts/shared/components/field_error_message.tsx +29 -0
  84. package/src/components/layouts/shared/components/form_action_buttons.tsx +64 -0
  85. package/src/components/layouts/shared/components/form_field_wrapper.tsx +44 -0
  86. package/src/components/layouts/shared/components/form_header.tsx +36 -0
  87. package/src/components/layouts/shared/components/logout_button.tsx +76 -0
  88. package/src/components/layouts/shared/components/password_field.tsx +72 -0
  89. package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +264 -0
  90. package/src/components/layouts/shared/components/two_column_auth_layout.tsx +44 -0
  91. package/src/components/layouts/shared/components/unauthorized_guard.tsx +78 -0
  92. package/src/components/layouts/shared/components/visual_panel.tsx +41 -0
  93. package/src/components/layouts/shared/config/layout_customization.ts +95 -0
  94. package/src/components/layouts/shared/data/layout_data_client.ts +19 -0
  95. package/src/components/layouts/shared/hooks/use_auth_status.ts +103 -0
  96. package/src/components/layouts/shared/utils/ip_address.ts +37 -0
  97. package/src/components/layouts/shared/utils/validation.ts +66 -0
  98. package/src/components/ui/avatar.tsx +50 -0
  99. package/src/components/ui/button.tsx +57 -0
  100. package/src/components/ui/dialog.tsx +122 -0
  101. package/src/components/ui/hazo_ui_tooltip.tsx +67 -0
  102. package/src/components/ui/input.tsx +22 -0
  103. package/src/components/ui/label.tsx +26 -0
  104. package/src/components/ui/separator.tsx +31 -0
  105. package/src/components/ui/sheet.tsx +139 -0
  106. package/src/components/ui/sidebar.tsx +773 -0
  107. package/src/components/ui/skeleton.tsx +15 -0
  108. package/src/components/ui/sonner.tsx +31 -0
  109. package/src/components/ui/switch.tsx +29 -0
  110. package/src/components/ui/tabs.tsx +55 -0
  111. package/src/components/ui/tooltip.tsx +32 -0
  112. package/src/components/ui/vertical-tabs.tsx +59 -0
  113. package/src/hooks/use-mobile.tsx +19 -0
  114. package/src/lib/already_logged_in_config.server.ts +46 -0
  115. package/src/lib/app_logger.ts +24 -0
  116. package/src/lib/auth/auth_utils.server.ts +196 -0
  117. package/src/lib/auth/server_auth.ts +88 -0
  118. package/src/lib/config/config_loader.server.ts +149 -0
  119. package/src/lib/email_verification_config.server.ts +32 -0
  120. package/src/lib/file_types_config.server.ts +25 -0
  121. package/src/lib/forgot_password_config.server.ts +32 -0
  122. package/src/lib/hazo_connect_instance.server.ts +77 -0
  123. package/src/lib/hazo_connect_setup.server.ts +181 -0
  124. package/src/lib/hazo_connect_setup.ts +54 -0
  125. package/src/lib/login_config.server.ts +46 -0
  126. package/src/lib/messages_config.server.ts +45 -0
  127. package/src/lib/migrations/apply_migration.ts +105 -0
  128. package/src/lib/my_settings_config.server.ts +135 -0
  129. package/src/lib/password_requirements_config.server.ts +39 -0
  130. package/src/lib/profile_picture_config.server.ts +56 -0
  131. package/src/lib/register_config.server.ts +57 -0
  132. package/src/lib/reset_password_config.server.ts +75 -0
  133. package/src/lib/services/email_service.ts +581 -0
  134. package/src/lib/services/email_verification_service.ts +264 -0
  135. package/src/lib/services/login_service.ts +118 -0
  136. package/src/lib/services/password_change_service.ts +154 -0
  137. package/src/lib/services/password_reset_service.ts +405 -0
  138. package/src/lib/services/profile_picture_remove_service.ts +120 -0
  139. package/src/lib/services/profile_picture_service.ts +215 -0
  140. package/src/lib/services/profile_picture_source_mapper.ts +62 -0
  141. package/src/lib/services/registration_service.ts +163 -0
  142. package/src/lib/services/token_service.ts +240 -0
  143. package/src/lib/services/user_update_service.ts +128 -0
  144. package/src/lib/ui_sizes_config.server.ts +37 -0
  145. package/src/lib/user_fields_config.server.ts +31 -0
  146. package/src/lib/utils/api_route_helpers.ts +60 -0
  147. package/src/lib/utils.ts +11 -0
  148. package/src/middleware.ts +91 -0
  149. package/src/server/config/config_loader.ts +496 -0
  150. package/src/server/index.ts +38 -0
  151. package/src/server/logging/logger_service.ts +56 -0
  152. package/src/server/routes/root_router.ts +16 -0
  153. package/src/server/server.ts +28 -0
  154. package/src/server/types/app_types.ts +74 -0
  155. package/src/server/types/express.d.ts +15 -0
  156. package/src/stories/email_verification_layout.stories.tsx +137 -0
  157. package/src/stories/forgot_password_layout.stories.tsx +85 -0
  158. package/src/stories/login_layout.stories.tsx +85 -0
  159. package/src/stories/project_overview.stories.tsx +33 -0
  160. package/src/stories/register_layout.stories.tsx +107 -0
  161. package/tailwind.config.ts +77 -0
  162. package/tsconfig.json +27 -0
@@ -0,0 +1,947 @@
1
+ // file_description: SQLite admin UI client component for browsing and editing database tables
2
+ "use client"
3
+
4
+ import { useEffect, useMemo, useState } from "react"
5
+ import {
6
+ Filter,
7
+ Loader2,
8
+ Pencil,
9
+ Plus,
10
+ RefreshCw,
11
+ Save,
12
+ Trash2,
13
+ X
14
+ } from "lucide-react"
15
+ import { toast } from "sonner"
16
+ import type {
17
+ SqliteFilterOperator,
18
+ TableColumn,
19
+ TableSchema,
20
+ TableSummary
21
+ } from "hazo_connect/ui"
22
+
23
+ type FilterState = {
24
+ column?: string
25
+ operator: SqliteFilterOperator
26
+ value: string
27
+ }
28
+
29
+ type SqlValue = Record<string, unknown>
30
+ type DataResponse = {
31
+ data: SqlValue[]
32
+ total: number
33
+ }
34
+
35
+ const DEFAULT_LIMIT = 20
36
+ const filterOperators: { label: string; value: SqliteFilterOperator }[] = [
37
+ { label: "Equals", value: "eq" },
38
+ { label: "Not equal", value: "neq" },
39
+ { label: "Greater than", value: "gt" },
40
+ { label: "Greater or equal", value: "gte" },
41
+ { label: "Less than", value: "lt" },
42
+ { label: "Less or equal", value: "lte" },
43
+ { label: "Contains", value: "like" },
44
+ { label: "Contains (case-insensitive)", value: "ilike" },
45
+ { label: "Is / Is Not", value: "is" }
46
+ ]
47
+
48
+ export default function SqliteAdminClient({
49
+ initialTables
50
+ }: {
51
+ initialTables: TableSummary[]
52
+ }) {
53
+ const [tables, setTables] = useState<TableSummary[]>(initialTables)
54
+ const [selectedTable, setSelectedTable] = useState<TableSummary | null>(
55
+ initialTables[0] ?? null
56
+ )
57
+ const [schema, setSchema] = useState<TableSchema | null>(null)
58
+ const [rows, setRows] = useState<SqlValue[]>([])
59
+ const [totalRows, setTotalRows] = useState(0)
60
+ const [limit, setLimit] = useState(DEFAULT_LIMIT)
61
+ const [offset, setOffset] = useState(0)
62
+ const [orderBy, setOrderBy] = useState<string | undefined>()
63
+ const [orderDirection, setOrderDirection] = useState<"asc" | "desc">("asc")
64
+ const [filterState, setFilterState] = useState<FilterState>({
65
+ operator: "eq",
66
+ value: ""
67
+ })
68
+
69
+ const [isLoadingSchema, setIsLoadingSchema] = useState(false)
70
+ const [isLoadingData, setIsLoadingData] = useState(false)
71
+ const [isRefreshingTables, setIsRefreshingTables] = useState(false)
72
+ const [isCreateOpen, setIsCreateOpen] = useState(false)
73
+ const [editingRow, setEditingRow] = useState<SqlValue | null>(null)
74
+
75
+ const columns = useMemo<TableColumn[]>(() => schema?.columns ?? [], [schema])
76
+ const filterColumns = useMemo(
77
+ () => columns.filter(column => identifierIsFilterable(column.name)),
78
+ [columns]
79
+ )
80
+
81
+ useEffect(() => {
82
+ if (!selectedTable) {
83
+ return
84
+ }
85
+ void loadSchemaAndData(selectedTable.name)
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [selectedTable?.name])
88
+
89
+ async function loadSchemaAndData(tableName: string) {
90
+ setIsLoadingSchema(true)
91
+ try {
92
+ const schemaResponse = await fetch(`/hazo_connect/api/sqlite/schema?table=${encodeURIComponent(tableName)}`)
93
+ if (!schemaResponse.ok) {
94
+ throw new Error(await schemaResponse.text())
95
+ }
96
+ const schemaJson = await schemaResponse.json()
97
+ setSchema(schemaJson.data as TableSchema)
98
+ const preferredFilterColumn =
99
+ filterState.column && schemaJson.data?.columns?.some((col: TableColumn) => col.name === filterState.column)
100
+ ? filterState.column
101
+ : schemaJson.data?.columns?.[0]?.name
102
+ setFilterState(current => ({
103
+ ...current,
104
+ column: preferredFilterColumn
105
+ }))
106
+ setLimit(DEFAULT_LIMIT)
107
+ setOffset(0)
108
+ setOrderBy(undefined)
109
+ setOrderDirection("asc")
110
+ await loadData(tableName, {
111
+ limit: DEFAULT_LIMIT,
112
+ offset: 0,
113
+ orderBy: undefined,
114
+ orderDirection: "asc",
115
+ filterOverride: {
116
+ ...filterState,
117
+ column: preferredFilterColumn
118
+ }
119
+ })
120
+ } catch (error) {
121
+ toast.error(
122
+ error instanceof Error
123
+ ? error.message
124
+ : `Failed to load schema for table '${tableName}'`
125
+ )
126
+ } finally {
127
+ setIsLoadingSchema(false)
128
+ }
129
+ }
130
+
131
+ async function loadData(
132
+ tableName: string,
133
+ overrides?: {
134
+ limit?: number
135
+ offset?: number
136
+ orderBy?: string
137
+ orderDirection?: "asc" | "desc"
138
+ filterOverride?: FilterState
139
+ }
140
+ ) {
141
+ setIsLoadingData(true)
142
+ const nextLimit = overrides?.limit ?? limit
143
+ const nextOffset = overrides?.offset ?? offset
144
+ const nextOrderBy = overrides?.orderBy ?? orderBy
145
+ const nextOrderDirection = overrides?.orderDirection ?? orderDirection
146
+ const nextFilter = overrides?.filterOverride ?? filterState
147
+
148
+ try {
149
+ const params = new URLSearchParams({
150
+ table: tableName,
151
+ limit: String(nextLimit),
152
+ offset: String(nextOffset)
153
+ })
154
+
155
+ if (nextOrderBy) {
156
+ params.set("orderBy", nextOrderBy)
157
+ params.set("orderDirection", nextOrderDirection)
158
+ }
159
+
160
+ if (nextFilter.column && nextFilter.value.trim().length) {
161
+ const key =
162
+ nextFilter.operator === "eq"
163
+ ? `filter[${nextFilter.column}]`
164
+ : `filter[${nextFilter.column}][${nextFilter.operator}]`
165
+ params.set(key, nextFilter.value)
166
+ } else if (nextFilter.operator === "is" && nextFilter.column) {
167
+ const key = `filter[${nextFilter.column}][is]`
168
+ params.set(key, nextFilter.value || "null")
169
+ }
170
+
171
+ const response = await fetch(`/hazo_connect/api/sqlite/data?${params.toString()}`)
172
+ if (!response.ok) {
173
+ throw new Error(await response.text())
174
+ }
175
+
176
+ const json = (await response.json()) as DataResponse
177
+ setRows(json.data ?? [])
178
+ setTotalRows(json.total ?? 0)
179
+ setLimit(nextLimit)
180
+ setOffset(nextOffset)
181
+ setOrderBy(nextOrderBy)
182
+ setOrderDirection(nextOrderDirection)
183
+ setFilterState(nextFilter)
184
+ } catch (error) {
185
+ toast.error(
186
+ error instanceof Error
187
+ ? error.message
188
+ : `Failed to load data for table '${tableName}'`
189
+ )
190
+ } finally {
191
+ setIsLoadingData(false)
192
+ }
193
+ }
194
+
195
+ async function refreshTables() {
196
+ setIsRefreshingTables(true)
197
+ try {
198
+ const response = await fetch("/hazo_connect/api/sqlite/tables")
199
+ if (!response.ok) {
200
+ throw new Error(await response.text())
201
+ }
202
+ const json = await response.json()
203
+ setTables(json.data ?? [])
204
+ toast.success("Tables refreshed")
205
+ } catch (error) {
206
+ toast.error(
207
+ error instanceof Error ? error.message : "Failed to refresh tables"
208
+ )
209
+ } finally {
210
+ setIsRefreshingTables(false)
211
+ }
212
+ }
213
+
214
+ function handleSelectTable(table: TableSummary) {
215
+ setSelectedTable(table)
216
+ }
217
+
218
+ function handleChangePage(nextOffset: number) {
219
+ if (!selectedTable) {
220
+ return
221
+ }
222
+ void loadData(selectedTable.name, { offset: Math.max(0, nextOffset) })
223
+ }
224
+
225
+ function handleChangeLimit(nextLimit: number) {
226
+ if (!selectedTable) {
227
+ return
228
+ }
229
+ void loadData(selectedTable.name, { limit: nextLimit, offset: 0 })
230
+ }
231
+
232
+ function handleChangeOrder(column?: string) {
233
+ if (!selectedTable) {
234
+ return
235
+ }
236
+ const nextDirection =
237
+ orderBy === column
238
+ ? orderDirection === "asc"
239
+ ? "desc"
240
+ : "asc"
241
+ : "asc"
242
+ void loadData(selectedTable.name, {
243
+ orderBy: column,
244
+ orderDirection: nextDirection
245
+ })
246
+ }
247
+
248
+ function handleApplyFilter() {
249
+ if (!selectedTable) {
250
+ return
251
+ }
252
+ void loadData(selectedTable.name, { offset: 0 })
253
+ }
254
+
255
+ async function handleInsertRow(data: Record<string, unknown>) {
256
+ if (!selectedTable) {
257
+ return
258
+ }
259
+
260
+ try {
261
+ const response = await fetch("/hazo_connect/api/sqlite/data", {
262
+ method: "POST",
263
+ headers: {
264
+ "Content-Type": "application/json"
265
+ },
266
+ body: JSON.stringify({ table: selectedTable.name, data })
267
+ })
268
+
269
+ if (!response.ok) {
270
+ throw new Error(await response.text())
271
+ }
272
+
273
+ toast.success("Row inserted")
274
+ setIsCreateOpen(false)
275
+ await Promise.all([
276
+ refreshTables(),
277
+ loadData(selectedTable.name, { offset: 0 })
278
+ ])
279
+ } catch (error) {
280
+ toast.error(error instanceof Error ? error.message : "Insert failed")
281
+ }
282
+ }
283
+
284
+ async function handleUpdateRow(
285
+ row: SqlValue,
286
+ data: Record<string, unknown>
287
+ ) {
288
+ if (!selectedTable || !schema) {
289
+ return
290
+ }
291
+
292
+ const criteria = buildCriteriaFromRow(row, schema.columns)
293
+ try {
294
+ const response = await fetch("/hazo_connect/api/sqlite/data", {
295
+ method: "PATCH",
296
+ headers: {
297
+ "Content-Type": "application/json"
298
+ },
299
+ body: JSON.stringify({
300
+ table: selectedTable.name,
301
+ data,
302
+ criteria
303
+ })
304
+ })
305
+
306
+ if (!response.ok) {
307
+ throw new Error(await response.text())
308
+ }
309
+
310
+ toast.success("Row updated")
311
+ setEditingRow(null)
312
+ await loadData(selectedTable.name)
313
+ } catch (error) {
314
+ toast.error(error instanceof Error ? error.message : "Update failed")
315
+ }
316
+ }
317
+
318
+ async function handleDeleteRow(row: SqlValue) {
319
+ if (!selectedTable || !schema) {
320
+ return
321
+ }
322
+
323
+ const criteria = buildCriteriaFromRow(row, schema.columns)
324
+ if (!Object.keys(criteria).length) {
325
+ toast.error("Unable to determine primary key for row deletion")
326
+ return
327
+ }
328
+
329
+ if (!window.confirm("Delete this row? This action cannot be undone.")) {
330
+ return
331
+ }
332
+
333
+ try {
334
+ const response = await fetch("/hazo_connect/api/sqlite/data", {
335
+ method: "DELETE",
336
+ headers: {
337
+ "Content-Type": "application/json"
338
+ },
339
+ body: JSON.stringify({
340
+ table: selectedTable.name,
341
+ criteria
342
+ })
343
+ })
344
+
345
+ if (!response.ok) {
346
+ throw new Error(await response.text())
347
+ }
348
+
349
+ toast.success("Row deleted")
350
+ await loadData(selectedTable.name, { offset: 0 })
351
+ await refreshTables()
352
+ } catch (error) {
353
+ toast.error(error instanceof Error ? error.message : "Delete failed")
354
+ }
355
+ }
356
+
357
+ const totalPages = Math.max(1, Math.ceil(totalRows / limit))
358
+ const currentPage = Math.min(totalPages, Math.floor(offset / limit) + 1)
359
+
360
+ return (
361
+ <div className="flex min-h-[calc(100vh-4rem)] flex-col gap-4 p-6 lg:flex-row">
362
+ <aside className="w-full max-w-xs rounded-lg border border-slate-200 bg-white shadow-sm lg:sticky lg:top-6 lg:h-[calc(100vh-6rem)]">
363
+ <header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
364
+ <h2 className="text-sm font-medium text-slate-700">Tables</h2>
365
+ <button
366
+ type="button"
367
+ onClick={refreshTables}
368
+ disabled={isRefreshingTables}
369
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
370
+ >
371
+ {isRefreshingTables ? (
372
+ <Loader2 className="h-3 w-3 animate-spin" />
373
+ ) : (
374
+ <RefreshCw className="h-3 w-3" />
375
+ )}
376
+ Refresh
377
+ </button>
378
+ </header>
379
+ <nav className="max-h-[calc(100vh-9rem)] overflow-auto px-2 py-3 text-sm">
380
+ {tables.length === 0 ? (
381
+ <p className="rounded-md bg-slate-50 p-3 text-slate-500">
382
+ No tables detected.
383
+ </p>
384
+ ) : (
385
+ <ul className="space-y-1">
386
+ {tables.map(table => {
387
+ const isActive = selectedTable?.name === table.name
388
+ return (
389
+ <li key={table.name}>
390
+ <button
391
+ type="button"
392
+ onClick={() => handleSelectTable(table)}
393
+ className={[
394
+ "flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition",
395
+ isActive
396
+ ? "bg-slate-900 text-white shadow-sm"
397
+ : "bg-white text-slate-700 hover:bg-slate-100"
398
+ ].join(" ")}
399
+ >
400
+ <span className="truncate font-medium">{table.name}</span>
401
+ {typeof table.row_count === "number" && (
402
+ <span
403
+ className={[
404
+ "ml-2 inline-flex min-w-[2rem] items-center justify-center rounded-full px-2 text-xs",
405
+ isActive
406
+ ? "bg-slate-800 text-slate-100"
407
+ : "bg-slate-100 text-slate-600"
408
+ ].join(" ")}
409
+ >
410
+ {table.row_count}
411
+ </span>
412
+ )}
413
+ </button>
414
+ </li>
415
+ )
416
+ })}
417
+ </ul>
418
+ )}
419
+ </nav>
420
+ </aside>
421
+
422
+ <section className="flex-1 space-y-6">
423
+ <header className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
424
+ <div className="flex flex-wrap items-center justify-between gap-3">
425
+ <div>
426
+ <h1 className="text-xl font-semibold text-slate-900">
427
+ SQLite Admin
428
+ </h1>
429
+ <p className="text-sm text-slate-500">
430
+ Browse tables, inspect schema, and edit data safely.
431
+ </p>
432
+ </div>
433
+ <div className="flex items-center gap-2">
434
+ <button
435
+ type="button"
436
+ onClick={() => selectedTable && loadSchemaAndData(selectedTable.name)}
437
+ disabled={isLoadingSchema || isLoadingData}
438
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
439
+ >
440
+ {isLoadingSchema || isLoadingData ? (
441
+ <Loader2 className="h-4 w-4 animate-spin" />
442
+ ) : (
443
+ <RefreshCw className="h-4 w-4" />
444
+ )}
445
+ Refresh
446
+ </button>
447
+ <button
448
+ type="button"
449
+ onClick={() => setIsCreateOpen(true)}
450
+ disabled={!selectedTable || !schema}
451
+ className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400"
452
+ >
453
+ <Plus className="h-4 w-4" />
454
+ Add Row
455
+ </button>
456
+ </div>
457
+ </div>
458
+
459
+ <div className="flex flex-wrap items-center gap-3 rounded-md bg-slate-50 p-3 text-sm text-slate-600">
460
+ <Filter className="h-4 w-4 text-slate-500" />
461
+ <select
462
+ className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
463
+ value={filterState.column ?? ""}
464
+ onChange={event =>
465
+ setFilterState(current => ({
466
+ ...current,
467
+ column: event.target.value || undefined
468
+ }))
469
+ }
470
+ >
471
+ {filterColumns.length === 0 ? (
472
+ <option value="">No filterable columns</option>
473
+ ) : (
474
+ filterColumns.map(column => (
475
+ <option key={column.name} value={column.name}>
476
+ {column.name}
477
+ </option>
478
+ ))
479
+ )}
480
+ </select>
481
+ <select
482
+ className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
483
+ value={filterState.operator}
484
+ onChange={event =>
485
+ setFilterState(current => ({
486
+ ...current,
487
+ operator: event.target.value as SqliteFilterOperator
488
+ }))
489
+ }
490
+ >
491
+ {filterOperators.map(operator => (
492
+ <option key={operator.value} value={operator.value}>
493
+ {operator.label}
494
+ </option>
495
+ ))}
496
+ </select>
497
+ {filterState.operator === "is" ? (
498
+ <select
499
+ className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
500
+ value={filterState.value}
501
+ onChange={event =>
502
+ setFilterState(current => ({
503
+ ...current,
504
+ value: event.target.value
505
+ }))
506
+ }
507
+ >
508
+ <option value="null">NULL</option>
509
+ <option value="not.null">NOT NULL</option>
510
+ </select>
511
+ ) : (
512
+ <input
513
+ type="text"
514
+ className="flex-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-sm focus:border-slate-400 focus:outline-none"
515
+ placeholder="Filter value…"
516
+ value={filterState.value}
517
+ onChange={event =>
518
+ setFilterState(current => ({
519
+ ...current,
520
+ value: event.target.value
521
+ }))
522
+ }
523
+ />
524
+ )}
525
+ <button
526
+ type="button"
527
+ onClick={handleApplyFilter}
528
+ disabled={!selectedTable || !schema}
529
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
530
+ >
531
+ Apply
532
+ </button>
533
+ </div>
534
+ </header>
535
+
536
+ {selectedTable ? (
537
+ <>
538
+ <section className="rounded-lg border border-slate-200 bg-white shadow-sm">
539
+ <header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
540
+ <div>
541
+ <h2 className="text-base font-semibold text-slate-800">
542
+ {selectedTable.name}
543
+ </h2>
544
+ <p className="text-xs text-slate-500">
545
+ {schema?.columns.length ?? 0} columns
546
+ </p>
547
+ </div>
548
+ <div className="flex items-center gap-2">
549
+ <label className="flex items-center gap-1 text-xs text-slate-500">
550
+ Rows per page
551
+ <select
552
+ value={limit}
553
+ onChange={event =>
554
+ handleChangeLimit(Number.parseInt(event.target.value, 10))
555
+ }
556
+ className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm"
557
+ >
558
+ {[10, 20, 50, 100].map(option => (
559
+ <option key={option} value={option}>
560
+ {option}
561
+ </option>
562
+ ))}
563
+ </select>
564
+ </label>
565
+ <div className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm">
566
+ <button
567
+ type="button"
568
+ onClick={() => handleChangeOrder(orderBy)}
569
+ className="inline-flex items-center gap-1"
570
+ disabled={!schema}
571
+ >
572
+ Order:{" "}
573
+ {orderBy ? `${orderBy} • ${orderDirection}` : "not set"}
574
+ </button>
575
+ {schema && schema.columns.length > 0 && (
576
+ <select
577
+ value={orderBy ?? ""}
578
+ onChange={event =>
579
+ handleChangeOrder(event.target.value || undefined)
580
+ }
581
+ className="rounded border border-slate-200 bg-white px-1 py-0.5 text-xs"
582
+ >
583
+ <option value="">None</option>
584
+ {schema.columns.map(column => (
585
+ <option key={column.name} value={column.name}>
586
+ {column.name}
587
+ </option>
588
+ ))}
589
+ </select>
590
+ )}
591
+ </div>
592
+ </div>
593
+ </header>
594
+
595
+ <div className="overflow-auto">
596
+ <table className="min-w-full divide-y divide-slate-200 text-sm">
597
+ <thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
598
+ <tr>
599
+ {columns.map(column => (
600
+ <th key={column.name} className="px-3 py-2 text-left">
601
+ {column.name}
602
+ </th>
603
+ ))}
604
+ <th className="px-3 py-2 text-right text-slate-400">Actions</th>
605
+ </tr>
606
+ </thead>
607
+ <tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
608
+ {isLoadingData ? (
609
+ <tr>
610
+ <td colSpan={columns.length + 1} className="px-3 py-8 text-center">
611
+ <div className="inline-flex items-center gap-2 text-slate-500">
612
+ <Loader2 className="h-4 w-4 animate-spin" />
613
+ Loading data…
614
+ </div>
615
+ </td>
616
+ </tr>
617
+ ) : rows.length === 0 ? (
618
+ <tr>
619
+ <td colSpan={columns.length + 1} className="px-3 py-8 text-center text-slate-400">
620
+ No rows to display.
621
+ </td>
622
+ </tr>
623
+ ) : (
624
+ rows.map((row, index) => (
625
+ <tr key={index} className="hover:bg-slate-50/60">
626
+ {columns.map(column => (
627
+ <td key={column.name} className="max-w-[200px] truncate px-3 py-2">
628
+ {formatCellValue(row[column.name])}
629
+ </td>
630
+ ))}
631
+ <td className="px-3 py-2 text-right">
632
+ <div className="inline-flex items-center gap-1">
633
+ <button
634
+ type="button"
635
+ onClick={() => setEditingRow(row)}
636
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100"
637
+ >
638
+ <Pencil className="h-3 w-3" />
639
+ Edit
640
+ </button>
641
+ <button
642
+ type="button"
643
+ onClick={() => handleDeleteRow(row)}
644
+ className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs font-medium text-red-600 transition hover:bg-red-50"
645
+ >
646
+ <Trash2 className="h-3 w-3" />
647
+ Delete
648
+ </button>
649
+ </div>
650
+ </td>
651
+ </tr>
652
+ ))
653
+ )}
654
+ </tbody>
655
+ </table>
656
+ </div>
657
+
658
+ <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
659
+ <span>
660
+ Page {currentPage} of {totalPages} • {totalRows} rows
661
+ </span>
662
+ <div className="flex items-center gap-2">
663
+ <button
664
+ type="button"
665
+ onClick={() => handleChangePage(Math.max(0, offset - limit))}
666
+ disabled={offset === 0 || isLoadingData}
667
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
668
+ >
669
+ Prev
670
+ </button>
671
+ <button
672
+ type="button"
673
+ onClick={() => handleChangePage(offset + limit)}
674
+ disabled={offset + limit >= totalRows || isLoadingData}
675
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
676
+ >
677
+ Next
678
+ </button>
679
+ </div>
680
+ </footer>
681
+ </section>
682
+
683
+ <section className="rounded-lg border border-slate-200 bg-white shadow-sm">
684
+ <header className="border-b border-slate-100 px-4 py-3">
685
+ <h3 className="text-sm font-semibold text-slate-700">Schema</h3>
686
+ </header>
687
+ <div className="overflow-auto">
688
+ <table className="min-w-full divide-y divide-slate-200 text-sm">
689
+ <thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
690
+ <tr>
691
+ <th className="px-3 py-2 text-left">Column</th>
692
+ <th className="px-3 py-2 text-left">Type</th>
693
+ <th className="px-3 py-2 text-left">Not null</th>
694
+ <th className="px-3 py-2 text-left">Default</th>
695
+ <th className="px-3 py-2 text-left">Primary key</th>
696
+ </tr>
697
+ </thead>
698
+ <tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
699
+ {columns.map(column => (
700
+ <tr key={column.name}>
701
+ <td className="px-3 py-2 font-medium">{column.name}</td>
702
+ <td className="px-3 py-2">{column.type || "TEXT"}</td>
703
+ <td className="px-3 py-2">
704
+ {column.notnull ? "Yes" : "No"}
705
+ </td>
706
+ <td className="px-3 py-2">
707
+ {column.default_value === null
708
+ ? "NULL"
709
+ : String(column.default_value)}
710
+ </td>
711
+ <td className="px-3 py-2">
712
+ {column.primary_key_position
713
+ ? `Yes (#${column.primary_key_position})`
714
+ : "No"}
715
+ </td>
716
+ </tr>
717
+ ))}
718
+ </tbody>
719
+ </table>
720
+ </div>
721
+ </section>
722
+ </>
723
+ ) : (
724
+ <p className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">
725
+ Select a table to begin.
726
+ </p>
727
+ )}
728
+ </section>
729
+
730
+ {schema && selectedTable && (
731
+ <RowModal
732
+ title={`Insert into ${selectedTable.name}`}
733
+ open={isCreateOpen}
734
+ columns={schema.columns}
735
+ onClose={() => setIsCreateOpen(false)}
736
+ onSubmit={handleInsertRow}
737
+ />
738
+ )}
739
+
740
+ {schema && selectedTable && editingRow && (
741
+ <RowModal
742
+ title={`Edit row in ${selectedTable.name}`}
743
+ open={Boolean(editingRow)}
744
+ columns={schema.columns}
745
+ initialValues={editingRow}
746
+ onClose={() => setEditingRow(null)}
747
+ onSubmit={data => handleUpdateRow(editingRow, data)}
748
+ primaryKeys={schema.columns.filter(column => column.primary_key_position > 0)}
749
+ />
750
+ )}
751
+ </div>
752
+ )
753
+ }
754
+
755
+ function RowModal({
756
+ title,
757
+ open,
758
+ columns,
759
+ primaryKeys,
760
+ initialValues,
761
+ onClose,
762
+ onSubmit
763
+ }: {
764
+ title: string
765
+ open: boolean
766
+ columns: TableColumn[]
767
+ primaryKeys?: TableColumn[]
768
+ initialValues?: SqlValue
769
+ onClose: () => void
770
+ onSubmit: (data: Record<string, unknown>) => void
771
+ }) {
772
+ const [formValues, setFormValues] = useState<Record<string, string>>({})
773
+
774
+ useEffect(() => {
775
+ if (open) {
776
+ const nextValues: Record<string, string> = {}
777
+ columns.forEach(column => {
778
+ const rawValue = initialValues?.[column.name]
779
+ nextValues[column.name] =
780
+ rawValue === null || rawValue === undefined ? "" : String(rawValue)
781
+ })
782
+ setFormValues(nextValues)
783
+ }
784
+ }, [open, columns, initialValues])
785
+
786
+ if (!open) {
787
+ return null
788
+ }
789
+
790
+ function handleChange(column: string, value: string) {
791
+ setFormValues(current => ({
792
+ ...current,
793
+ [column]: value
794
+ }))
795
+ }
796
+
797
+ function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
798
+ event.preventDefault()
799
+ const payload: Record<string, unknown> = {}
800
+
801
+ for (const column of columns) {
802
+ const value = formValues[column.name]
803
+ payload[column.name] = coerceValue(column, value)
804
+ }
805
+
806
+ onSubmit(payload)
807
+ }
808
+
809
+ return (
810
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 backdrop-blur">
811
+ <div className="relative w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-6 shadow-xl">
812
+ <header className="mb-4 flex items-center justify-between">
813
+ <div>
814
+ <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
815
+ {primaryKeys && primaryKeys.length === 0 && (
816
+ <p className="mt-1 text-xs text-amber-600">
817
+ Warning: table has no primary key defined. Updates/deletes rely on full row
818
+ matching.
819
+ </p>
820
+ )}
821
+ </div>
822
+ <button
823
+ type="button"
824
+ onClick={onClose}
825
+ className="rounded-full border border-slate-200 p-1 text-slate-500 transition hover:bg-slate-100"
826
+ >
827
+ <X className="h-4 w-4" />
828
+ </button>
829
+ </header>
830
+
831
+ <form onSubmit={handleSubmit} className="space-y-4">
832
+ <div className="grid max-h-[50vh] grid-cols-1 gap-4 overflow-auto pr-2 sm:grid-cols-2">
833
+ {columns.map(column => (
834
+ <label key={column.name} className="flex flex-col gap-1 text-sm">
835
+ <span className="font-medium text-slate-700">
836
+ {column.name}
837
+ {column.notnull && (
838
+ <sup className="ml-1 text-amber-600" title="Required">
839
+ *
840
+ </sup>
841
+ )}
842
+ </span>
843
+ <input
844
+ type="text"
845
+ value={formValues[column.name] ?? ""}
846
+ onChange={event => handleChange(column.name, event.target.value)}
847
+ className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-slate-400 focus:outline-none"
848
+ placeholder={
849
+ column.default_value === null
850
+ ? column.type || "TEXT"
851
+ : String(column.default_value)
852
+ }
853
+ />
854
+ <span className="text-xs text-slate-400">
855
+ {column.type || "TEXT"}
856
+ {column.primary_key_position ? " • Primary key" : ""}
857
+ </span>
858
+ </label>
859
+ ))}
860
+ </div>
861
+
862
+ <footer className="flex items-center justify-end gap-2">
863
+ <button
864
+ type="button"
865
+ onClick={onClose}
866
+ className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100"
867
+ >
868
+ Cancel
869
+ </button>
870
+ <button
871
+ type="submit"
872
+ className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800"
873
+ >
874
+ <Save className="h-4 w-4" />
875
+ Save
876
+ </button>
877
+ </footer>
878
+ </form>
879
+ </div>
880
+ </div>
881
+ )
882
+ }
883
+
884
+ function buildCriteriaFromRow(row: SqlValue, columns: TableColumn[]) {
885
+ const criteria: Record<string, unknown> = {}
886
+ const primaryKeys = columns.filter(column => column.primary_key_position > 0)
887
+
888
+ if (primaryKeys.length) {
889
+ for (const primary of primaryKeys) {
890
+ criteria[primary.name] = row[primary.name]
891
+ }
892
+ return criteria
893
+ }
894
+
895
+ for (const column of columns) {
896
+ criteria[column.name] = row[column.name]
897
+ }
898
+ return criteria
899
+ }
900
+
901
+ function coerceValue(column: TableColumn, rawValue: string): unknown {
902
+ if (rawValue === "") {
903
+ return column.notnull ? "" : null
904
+ }
905
+
906
+ const normalisedType = (column.type ?? "").toLowerCase()
907
+
908
+ if (normalisedType.includes("int")) {
909
+ const parsed = Number.parseInt(rawValue, 10)
910
+ return Number.isNaN(parsed) ? rawValue : parsed
911
+ }
912
+
913
+ if (
914
+ normalisedType.includes("real") ||
915
+ normalisedType.includes("double") ||
916
+ normalisedType.includes("float") ||
917
+ normalisedType.includes("numeric")
918
+ ) {
919
+ const parsed = Number.parseFloat(rawValue)
920
+ return Number.isNaN(parsed) ? rawValue : parsed
921
+ }
922
+
923
+ if (normalisedType.includes("bool")) {
924
+ return rawValue === "true" || rawValue === "1" ? 1 : 0
925
+ }
926
+
927
+ return rawValue
928
+ }
929
+
930
+ function formatCellValue(value: unknown): string {
931
+ if (value === null || value === undefined) {
932
+ return "NULL"
933
+ }
934
+ if (typeof value === "object") {
935
+ try {
936
+ return JSON.stringify(value)
937
+ } catch {
938
+ return String(value)
939
+ }
940
+ }
941
+ return String(value)
942
+ }
943
+
944
+ function identifierIsFilterable(name: string): boolean {
945
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
946
+ }
947
+