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,91 @@
1
+ // file_description: API route to manually apply database migrations
2
+ // section: imports
3
+ import { NextRequest, NextResponse } from "next/server";
4
+ import { get_hazo_connect_instance } from "@/lib/hazo_connect_instance.server";
5
+ import { create_app_logger } from "@/lib/app_logger";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ import { get_filename, get_line_number } from "@/lib/utils/api_route_helpers";
9
+
10
+ // section: api_handler
11
+ export async function POST(request: NextRequest) {
12
+ const logger = create_app_logger();
13
+
14
+ try {
15
+ const body = await request.json();
16
+ const { migration_file } = body;
17
+
18
+ if (!migration_file) {
19
+ return NextResponse.json(
20
+ { error: "migration_file parameter is required" },
21
+ { status: 400 }
22
+ );
23
+ }
24
+
25
+ const migrations_dir = path.resolve(process.cwd(), "migrations");
26
+ const migration_path = path.join(migrations_dir, migration_file);
27
+
28
+ // Security check: ensure file is in migrations directory
29
+ if (!migration_path.startsWith(migrations_dir)) {
30
+ return NextResponse.json(
31
+ { error: "Invalid migration file path" },
32
+ { status: 400 }
33
+ );
34
+ }
35
+
36
+ if (!fs.existsSync(migration_path)) {
37
+ return NextResponse.json(
38
+ { error: `Migration file not found: ${migration_file}` },
39
+ { status: 404 }
40
+ );
41
+ }
42
+
43
+ // Read the migration SQL
44
+ const migration_sql = fs.readFileSync(migration_path, "utf-8");
45
+
46
+ // Get hazo_connect instance
47
+ const hazoConnect = get_hazo_connect_instance();
48
+
49
+ // For SQLite, we need to execute raw SQL
50
+ // Since hazo_connect doesn't expose raw SQL execution directly,
51
+ // we'll need to use the SQLite admin API or a workaround
52
+ // For now, return instructions to apply manually
53
+
54
+ logger.info("migration_apply_requested", {
55
+ filename: get_filename(),
56
+ line_number: get_line_number(),
57
+ migration_file,
58
+ });
59
+
60
+ return NextResponse.json(
61
+ {
62
+ success: true,
63
+ message: "Migration file read successfully. Please apply manually via SQLite Admin UI.",
64
+ sql: migration_sql,
65
+ instructions: [
66
+ "1. Go to SQLite Admin UI at /hazo_connect/sqlite_admin",
67
+ "2. Select the hazo_refresh_tokens table",
68
+ "3. Use the SQL editor or execute the migration SQL manually",
69
+ `4. Migration SQL is provided in the 'sql' field of this response`,
70
+ ],
71
+ },
72
+ { status: 200 }
73
+ );
74
+ } catch (error) {
75
+ const error_message = error instanceof Error ? error.message : "Unknown error";
76
+ const error_stack = error instanceof Error ? error.stack : undefined;
77
+
78
+ logger.error("migration_apply_error", {
79
+ filename: get_filename(),
80
+ line_number: get_line_number(),
81
+ error_message,
82
+ error_stack,
83
+ });
84
+
85
+ return NextResponse.json(
86
+ { error: "Failed to process migration request" },
87
+ { status: 500 }
88
+ );
89
+ }
90
+ }
91
+
Binary file
Binary file
Binary file
@@ -0,0 +1,60 @@
1
+ // file_description: client component for forgot password page that initializes hazo_connect and renders forgot password layout
2
+ // section: client_directive
3
+ "use client";
4
+
5
+ // section: imports
6
+ import { useEffect, useState } from "react";
7
+ import forgot_password_layout from "@/components/layouts/forgot_password";
8
+ import { createLayoutDataClient } from "@/components/layouts/shared/data/layout_data_client";
9
+ import { create_sqlite_hazo_connect } from "@/lib/hazo_connect_setup";
10
+ import type { LayoutDataClient } from "@/components/layouts/shared/data/layout_data_client";
11
+
12
+ // section: types
13
+ type ForgotPasswordPageClientProps = {
14
+ alreadyLoggedInMessage?: string;
15
+ showLogoutButton?: boolean;
16
+ showReturnHomeButton?: boolean;
17
+ returnHomeButtonLabel?: string;
18
+ returnHomePath?: string;
19
+ };
20
+
21
+ // section: component
22
+ export function ForgotPasswordPageClient({
23
+ alreadyLoggedInMessage,
24
+ showLogoutButton,
25
+ showReturnHomeButton,
26
+ returnHomeButtonLabel,
27
+ returnHomePath,
28
+ }: ForgotPasswordPageClientProps) {
29
+ const [dataClient, setDataClient] = useState<LayoutDataClient<unknown> | null>(null);
30
+
31
+ useEffect(() => {
32
+ // Initialize hazo_connect on client side
33
+ const hazoConnect = create_sqlite_hazo_connect();
34
+ const client = createLayoutDataClient(hazoConnect);
35
+
36
+ setDataClient(client);
37
+ }, []);
38
+
39
+ // Show loading state while initializing
40
+ if (!dataClient) {
41
+ return <div className="cls_forgot_password_page_loading text-slate-600">Loading...</div>;
42
+ }
43
+
44
+ const ForgotPasswordLayout = forgot_password_layout;
45
+
46
+ return (
47
+ <ForgotPasswordLayout
48
+ image_src="/globe.svg"
49
+ image_alt="Illustration of a globe representing secure authentication workflows"
50
+ image_background_color="#e2e8f0"
51
+ data_client={dataClient}
52
+ alreadyLoggedInMessage={alreadyLoggedInMessage}
53
+ showLogoutButton={showLogoutButton}
54
+ showReturnHomeButton={showReturnHomeButton}
55
+ returnHomeButtonLabel={returnHomeButtonLabel}
56
+ returnHomePath={returnHomePath}
57
+ />
58
+ );
59
+ }
60
+
@@ -0,0 +1,24 @@
1
+ // file_description: render the forgot password page shell and mount the forgot password layout component within sidebar
2
+ // section: imports
3
+ import { SidebarLayoutWrapper } from "@/components/layouts/shared/components/sidebar_layout_wrapper";
4
+ import { ForgotPasswordPageClient } from "./forgot_password_page_client";
5
+ import { get_forgot_password_config } from "@/lib/forgot_password_config.server";
6
+
7
+ // section: component
8
+ export default function forgot_password_page() {
9
+ // Read forgot password configuration from hazo_auth_config.ini (server-side)
10
+ const forgotPasswordConfig = get_forgot_password_config();
11
+
12
+ return (
13
+ <SidebarLayoutWrapper>
14
+ <ForgotPasswordPageClient
15
+ alreadyLoggedInMessage={forgotPasswordConfig.alreadyLoggedInMessage}
16
+ showLogoutButton={forgotPasswordConfig.showLogoutButton}
17
+ showReturnHomeButton={forgotPasswordConfig.showReturnHomeButton}
18
+ returnHomeButtonLabel={forgotPasswordConfig.returnHomeButtonLabel}
19
+ returnHomePath={forgotPasswordConfig.returnHomePath}
20
+ />
21
+ </SidebarLayoutWrapper>
22
+ );
23
+ }
24
+
@@ -0,0 +1,89 @@
1
+ /* file_description: define global tailwind layers and theme tokens for ui_component */
2
+ @tailwind base;
3
+ @tailwind components;
4
+ @tailwind utilities;
5
+
6
+ /* section: light_mode_tokens */
7
+ @layer base {
8
+ :root {
9
+ --background: 0 0% 100%;
10
+ --foreground: 0 0% 3.9%;
11
+ --card: 0 0% 100%;
12
+ --card-foreground: 0 0% 3.9%;
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 0 0% 3.9%;
15
+ --primary: 0 0% 9%;
16
+ --primary-foreground: 0 0% 98%;
17
+ --secondary: 0 0% 96.1%;
18
+ --secondary-foreground: 0 0% 9%;
19
+ --muted: 0 0% 96.1%;
20
+ --muted-foreground: 0 0% 45.1%;
21
+ --accent: 0 0% 96.1%;
22
+ --accent-foreground: 0 0% 9%;
23
+ --destructive: 0 84.2% 60.2%;
24
+ --destructive-foreground: 0 0% 98%;
25
+ --border: 0 0% 89.8%;
26
+ --input: 0 0% 89.8%;
27
+ --ring: 0 0% 3.9%;
28
+ --chart-1: 12 76% 61%;
29
+ --chart-2: 173 58% 39%;
30
+ --chart-3: 197 37% 24%;
31
+ --chart-4: 43 74% 66%;
32
+ --chart-5: 27 87% 67%;
33
+ --radius: 0.5rem;
34
+ --sidebar-background: 0 0% 98%;
35
+ --sidebar-foreground: 240 5.3% 26.1%;
36
+ --sidebar-primary: 240 5.9% 10%;
37
+ --sidebar-primary-foreground: 0 0% 98%;
38
+ --sidebar-accent: 240 4.8% 95.9%;
39
+ --sidebar-accent-foreground: 240 5.9% 10%;
40
+ --sidebar-border: 220 13% 91%;
41
+ --sidebar-ring: 217.2 91.2% 59.8%;
42
+ }
43
+
44
+ /* section: dark_mode_tokens */
45
+ .dark {
46
+ --background: 0 0% 3.9%;
47
+ --foreground: 0 0% 98%;
48
+ --card: 0 0% 3.9%;
49
+ --card-foreground: 0 0% 98%;
50
+ --popover: 0 0% 3.9%;
51
+ --popover-foreground: 0 0% 98%;
52
+ --primary: 0 0% 98%;
53
+ --primary-foreground: 0 0% 9%;
54
+ --secondary: 0 0% 14.9%;
55
+ --secondary-foreground: 0 0% 98%;
56
+ --muted: 0 0% 14.9%;
57
+ --muted-foreground: 0 0% 63.9%;
58
+ --accent: 0 0% 14.9%;
59
+ --accent-foreground: 0 0% 98%;
60
+ --destructive: 0 62.8% 30.6%;
61
+ --destructive-foreground: 0 0% 98%;
62
+ --border: 0 0% 14.9%;
63
+ --input: 0 0% 14.9%;
64
+ --ring: 0 0% 83.1%;
65
+ --chart-1: 220 70% 50%;
66
+ --chart-2: 160 60% 45%;
67
+ --chart-3: 30 80% 55%;
68
+ --chart-4: 280 65% 60%;
69
+ --chart-5: 340 75% 55%;
70
+ --sidebar-background: 240 5.9% 10%;
71
+ --sidebar-foreground: 240 4.8% 95.9%;
72
+ --sidebar-primary: 224.3 76.3% 48%;
73
+ --sidebar-primary-foreground: 0 0% 100%;
74
+ --sidebar-accent: 240 3.7% 15.9%;
75
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
76
+ --sidebar-border: 240 3.7% 15.9%;
77
+ --sidebar-ring: 217.2 91.2% 59.8%;
78
+ }
79
+ }
80
+
81
+ /* section: element_defaults */
82
+ @layer base {
83
+ * {
84
+ @apply border-border;
85
+ }
86
+ body {
87
+ @apply bg-background text-foreground;
88
+ }
89
+ }
@@ -0,0 +1,197 @@
1
+ // file_description: API route to manage SQLite table data (CRUD operations) for admin UI
2
+ import { NextRequest, NextResponse } from "next/server"
3
+ import {
4
+ getSqliteAdminService,
5
+ type RowQueryOptions,
6
+ type SqliteFilterOperator,
7
+ type SqliteWhereFilter
8
+ } from "hazo_connect/server"
9
+ import { get_hazo_connect_instance } from "@/lib/hazo_connect_instance.server"
10
+
11
+ export const dynamic = "force-dynamic"
12
+ const allowedOperators: SqliteFilterOperator[] = [
13
+ "eq",
14
+ "neq",
15
+ "gt",
16
+ "gte",
17
+ "lt",
18
+ "lte",
19
+ "like",
20
+ "ilike",
21
+ "is"
22
+ ]
23
+
24
+ // Helper function to ensure admin service is initialized
25
+ function ensureAdminServiceInitialized() {
26
+ // Get singleton hazo_connect instance (initializes admin service if needed)
27
+ get_hazo_connect_instance();
28
+ return getSqliteAdminService();
29
+ }
30
+
31
+ export async function GET(request: NextRequest) {
32
+ const url = new URL(request.url)
33
+ const table = url.searchParams.get("table")
34
+ if (!table) {
35
+ return NextResponse.json(
36
+ { error: "Query parameter 'table' is required." },
37
+ { status: 400 }
38
+ )
39
+ }
40
+
41
+ try {
42
+ const service = ensureAdminServiceInitialized()
43
+ const options = parseRowQueryOptions(url.searchParams)
44
+ const page = await service.getTableData(table, options)
45
+ return NextResponse.json({ data: page.rows, total: page.total })
46
+ } catch (error) {
47
+ return toErrorResponse(error, `Failed to fetch data for table '${table}'`)
48
+ }
49
+ }
50
+
51
+ export async function POST(request: NextRequest) {
52
+ try {
53
+ const service = ensureAdminServiceInitialized()
54
+ const payload = await request.json()
55
+ const table = payload?.table
56
+ const data = payload?.data
57
+
58
+ if (!table || typeof data !== "object" || Array.isArray(data)) {
59
+ return NextResponse.json(
60
+ { error: "Request body must include 'table' and a 'data' object." },
61
+ { status: 400 }
62
+ )
63
+ }
64
+
65
+ const inserted = await service.insertRow(table, data)
66
+ return NextResponse.json({ data: inserted })
67
+ } catch (error) {
68
+ return toErrorResponse(error, "Failed to insert row")
69
+ }
70
+ }
71
+
72
+ export async function PATCH(request: NextRequest) {
73
+ try {
74
+ const service = ensureAdminServiceInitialized()
75
+ const payload = await request.json()
76
+ const table = payload?.table
77
+ const data = payload?.data
78
+ const criteria = payload?.criteria
79
+
80
+ if (
81
+ !table ||
82
+ typeof data !== "object" ||
83
+ Array.isArray(data) ||
84
+ typeof criteria !== "object" ||
85
+ criteria === null ||
86
+ Array.isArray(criteria)
87
+ ) {
88
+ return NextResponse.json(
89
+ {
90
+ error:
91
+ "Request body must include 'table', 'data' object, and a 'criteria' object for the rows to update."
92
+ },
93
+ { status: 400 }
94
+ )
95
+ }
96
+
97
+ const rows = await service.updateRows(table, criteria, data)
98
+ return NextResponse.json({ data: rows, updated: rows.length })
99
+ } catch (error) {
100
+ return toErrorResponse(error, "Failed to update rows")
101
+ }
102
+ }
103
+
104
+ export async function DELETE(request: NextRequest) {
105
+ try {
106
+ const service = ensureAdminServiceInitialized()
107
+ const payload = await request.json()
108
+ const table = payload?.table
109
+ const criteria = payload?.criteria
110
+
111
+ if (
112
+ !table ||
113
+ typeof criteria !== "object" ||
114
+ criteria === null ||
115
+ Array.isArray(criteria)
116
+ ) {
117
+ return NextResponse.json(
118
+ {
119
+ error:
120
+ "Request body must include 'table' and a 'criteria' object for the rows to delete."
121
+ },
122
+ { status: 400 }
123
+ )
124
+ }
125
+
126
+ const rows = await service.deleteRows(table, criteria)
127
+ return NextResponse.json({ data: rows, deleted: rows.length })
128
+ } catch (error) {
129
+ return toErrorResponse(error, "Failed to delete rows")
130
+ }
131
+ }
132
+
133
+ function parseRowQueryOptions(params: URLSearchParams): RowQueryOptions {
134
+ const limitParam = params.get("limit")
135
+ const offsetParam = params.get("offset")
136
+ const orderBy = params.get("orderBy") ?? undefined
137
+ const orderDirection = parseOrderDirection(params.get("orderDirection"))
138
+ const filters = parseFilters(params)
139
+
140
+ const limit = limitParam ? Number.parseInt(limitParam, 10) : undefined
141
+ const offset = offsetParam ? Number.parseInt(offsetParam, 10) : undefined
142
+
143
+ return {
144
+ limit: Number.isNaN(limit) ? undefined : limit,
145
+ offset: Number.isNaN(offset) ? undefined : offset,
146
+ order_by: orderBy ?? undefined,
147
+ order_direction: orderDirection,
148
+ filters: filters.length ? filters : undefined
149
+ }
150
+ }
151
+
152
+ function parseFilters(params: URLSearchParams): SqliteWhereFilter[] {
153
+ const filters: SqliteWhereFilter[] = []
154
+
155
+ params.forEach((value, key) => {
156
+ const match = key.match(/^filter\[(.+?)\](?:\[(.+)\])?$/)
157
+ if (!match) {
158
+ return
159
+ }
160
+ const column = match[1]
161
+ const operatorValue = (match[2] ?? "eq").toLowerCase()
162
+ if (!isAllowedOperator(operatorValue)) {
163
+ throw new Error(`Unsupported filter operator '${operatorValue}'`)
164
+ }
165
+ filters.push({
166
+ column,
167
+ operator: operatorValue as SqliteFilterOperator,
168
+ value
169
+ })
170
+ })
171
+
172
+ return filters
173
+ }
174
+
175
+ function parseOrderDirection(
176
+ direction: string | null
177
+ ): "asc" | "desc" | undefined {
178
+ if (!direction) {
179
+ return undefined
180
+ }
181
+ const normalized = direction.toLowerCase()
182
+ if (normalized === "asc" || normalized === "desc") {
183
+ return normalized
184
+ }
185
+ throw new Error(`Unsupported order direction '${direction}'`)
186
+ }
187
+
188
+ function isAllowedOperator(value: string): value is SqliteFilterOperator {
189
+ return allowedOperators.includes(value as SqliteFilterOperator)
190
+ }
191
+
192
+ function toErrorResponse(error: unknown, fallback: string) {
193
+ const message = error instanceof Error ? error.message : fallback
194
+ const status = message.toLowerCase().includes("required") ? 400 : 500
195
+ return NextResponse.json({ error: message }, { status })
196
+ }
197
+
@@ -0,0 +1,35 @@
1
+ // file_description: API route to get SQLite table schema for admin UI
2
+ import { NextRequest, NextResponse } from "next/server"
3
+ import { getSqliteAdminService } from "hazo_connect/server"
4
+ import { get_hazo_connect_instance } from "@/lib/hazo_connect_instance.server"
5
+
6
+ export const dynamic = "force-dynamic"
7
+
8
+ export async function GET(request: NextRequest) {
9
+ const url = new URL(request.url)
10
+ const table = url.searchParams.get("table")
11
+ if (!table) {
12
+ return NextResponse.json(
13
+ { error: "Query parameter 'table' is required." },
14
+ { status: 400 }
15
+ )
16
+ }
17
+
18
+ try {
19
+ // Get singleton hazo_connect instance (initializes admin service if needed)
20
+ get_hazo_connect_instance();
21
+
22
+ const service = getSqliteAdminService()
23
+ const schema = await service.getTableSchema(table)
24
+ return NextResponse.json({ data: schema })
25
+ } catch (error) {
26
+ return toErrorResponse(error, `Failed to fetch schema for table '${table}'`)
27
+ }
28
+ }
29
+
30
+ function toErrorResponse(error: unknown, fallback: string) {
31
+ const message = error instanceof Error ? error.message : fallback
32
+ const status = message.toLowerCase().includes("required") ? 400 : 500
33
+ return NextResponse.json({ error: message }, { status })
34
+ }
35
+
@@ -0,0 +1,26 @@
1
+ // file_description: API route to list SQLite tables for admin UI
2
+ import { NextResponse } from "next/server"
3
+ import { getSqliteAdminService } from "hazo_connect/server"
4
+ import { get_hazo_connect_instance } from "@/lib/hazo_connect_instance.server"
5
+
6
+ export const dynamic = "force-dynamic"
7
+
8
+ export async function GET() {
9
+ try {
10
+ // Get singleton hazo_connect instance (initializes admin service if needed)
11
+ get_hazo_connect_instance();
12
+
13
+ const service = getSqliteAdminService()
14
+ const tables = await service.listTables()
15
+ return NextResponse.json({ data: tables })
16
+ } catch (error) {
17
+ return toErrorResponse(error, "Failed to list SQLite tables")
18
+ }
19
+ }
20
+
21
+ function toErrorResponse(error: unknown, fallback: string) {
22
+ const message = error instanceof Error ? error.message : fallback
23
+ const status = message.toLowerCase().includes("required") ? 400 : 500
24
+ return NextResponse.json({ error: message }, { status })
25
+ }
26
+
@@ -0,0 +1,51 @@
1
+ // file_description: SQLite admin UI page that displays the admin client component
2
+ // Note: This page fetches data from API routes instead of directly calling admin service
3
+ // to avoid SQLite/WASM loading issues in React Server Component context
4
+ import { headers } from "next/headers"
5
+ import type { TableSummary } from "hazo_connect/ui"
6
+ import SqliteAdminClient from "./sqlite-admin-client"
7
+
8
+ export const dynamic = "force-dynamic"
9
+
10
+ export default async function SqliteAdminPage() {
11
+ // Fetch initial tables from API route to avoid SQLite/WASM in RSC context
12
+ // API routes run in proper Node.js context where SQLite can work
13
+ try {
14
+ // Get the host from request headers to construct absolute URL
15
+ const headersList = await headers()
16
+ const host = headersList.get("host") || "localhost:3000"
17
+ const protocol = process.env.NODE_ENV === "production" ? "https" : "http"
18
+ const baseUrl = `${protocol}://${host}`
19
+
20
+ // Use Next.js internal fetch with absolute URL
21
+ const response = await fetch(
22
+ `${baseUrl}/hazo_connect/api/sqlite/tables`,
23
+ {
24
+ cache: "no-store"
25
+ }
26
+ );
27
+
28
+ if (!response.ok) {
29
+ const errorData = await response.json().catch(() => ({}));
30
+ throw new Error(errorData.error || `Failed to fetch tables: ${response.statusText}`);
31
+ }
32
+
33
+ const data = await response.json();
34
+ const tables: TableSummary[] = data.data || [];
35
+
36
+ return <SqliteAdminClient initialTables={tables} />
37
+ } catch (error) {
38
+ const message =
39
+ error instanceof Error ? error.message : "Failed to initialise SQLite admin UI."
40
+
41
+ return (
42
+ <section className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
43
+ <h1 className="text-2xl font-semibold text-slate-900">SQLite Admin</h1>
44
+ <p className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">
45
+ {message}
46
+ </p>
47
+ </section>
48
+ )
49
+ }
50
+ }
51
+