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,67 @@
1
+ // file_description: login layout specific configuration helpers
2
+ // section: imports
3
+ import type { LayoutFieldMap, LayoutFieldMapOverrides } from "@/components/layouts/shared/config/layout_customization";
4
+ import {
5
+ resolveButtonPalette,
6
+ resolveFieldDefinitions,
7
+ resolveLabels,
8
+ type ButtonPaletteDefaults,
9
+ type ButtonPaletteOverrides,
10
+ type LayoutLabelDefaults,
11
+ type LayoutLabelOverrides,
12
+ } from "@/components/layouts/shared/config/layout_customization";
13
+
14
+ // section: field_identifiers
15
+ export const LOGIN_FIELD_IDS = {
16
+ EMAIL: "email_address",
17
+ PASSWORD: "password",
18
+ } as const;
19
+
20
+ export type LoginFieldId = (typeof LOGIN_FIELD_IDS)[keyof typeof LOGIN_FIELD_IDS];
21
+
22
+ // section: field_definitions
23
+ const LOGIN_FIELD_DEFINITIONS: LayoutFieldMap = {
24
+ [LOGIN_FIELD_IDS.EMAIL]: {
25
+ id: LOGIN_FIELD_IDS.EMAIL,
26
+ label: "Email address",
27
+ type: "email",
28
+ autoComplete: "email",
29
+ placeholder: "Enter your email address",
30
+ ariaLabel: "Email address input field",
31
+ },
32
+ [LOGIN_FIELD_IDS.PASSWORD]: {
33
+ id: LOGIN_FIELD_IDS.PASSWORD,
34
+ label: "Password",
35
+ type: "password",
36
+ autoComplete: "current-password",
37
+ placeholder: "Enter your password",
38
+ ariaLabel: "Password input field",
39
+ },
40
+ };
41
+
42
+ export const createLoginFieldDefinitions = (
43
+ overrides?: LayoutFieldMapOverrides,
44
+ ) => resolveFieldDefinitions(LOGIN_FIELD_DEFINITIONS, overrides);
45
+
46
+ // section: label_defaults
47
+ const LOGIN_LABEL_DEFAULTS: LayoutLabelDefaults = {
48
+ heading: "Sign in to your account",
49
+ subHeading: "Enter your credentials to access your secure workspace.",
50
+ submitButton: "Login",
51
+ cancelButton: "Cancel",
52
+ };
53
+
54
+ export const resolveLoginLabels = (overrides?: LayoutLabelOverrides) =>
55
+ resolveLabels(LOGIN_LABEL_DEFAULTS, overrides);
56
+
57
+ // section: button_palette_defaults
58
+ const LOGIN_BUTTON_PALETTE_DEFAULTS: ButtonPaletteDefaults = {
59
+ submitBackground: "#0f172a",
60
+ submitText: "#ffffff",
61
+ cancelBorder: "#cbd5f5",
62
+ cancelText: "#0f172a",
63
+ };
64
+
65
+ export const resolveLoginButtonPalette = (overrides?: ButtonPaletteOverrides) =>
66
+ resolveButtonPalette(LOGIN_BUTTON_PALETTE_DEFAULTS, overrides);
67
+
@@ -0,0 +1,281 @@
1
+ // file_description: encapsulate login form state, validation, data interactions, IP collection, and login attempt logging
2
+ // section: imports
3
+ import { useCallback, useMemo, useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { LayoutDataClient } from "@/components/layouts/shared/data/layout_data_client";
6
+ import { LOGIN_FIELD_IDS, type LoginFieldId } from "@/components/layouts/login/config/login_field_config";
7
+ import { validateEmail } from "@/components/layouts/shared/utils/validation";
8
+ import { get_client_ip } from "@/components/layouts/shared/utils/ip_address";
9
+ import { trigger_auth_status_refresh } from "@/components/layouts/shared/hooks/use_auth_status";
10
+
11
+ // section: types
12
+ export type LoginFormValues = Record<LoginFieldId, string>;
13
+ export type LoginFormErrors = Partial<Record<LoginFieldId, string>>;
14
+ export type PasswordVisibilityState = {
15
+ password: boolean;
16
+ };
17
+
18
+ export type UseLoginFormParams<TClient = unknown> = {
19
+ dataClient: LayoutDataClient<TClient>;
20
+ logger?: {
21
+ info: (message: string, data?: Record<string, unknown>) => void;
22
+ error: (message: string, data?: Record<string, unknown>) => void;
23
+ warn: (message: string, data?: Record<string, unknown>) => void;
24
+ debug: (message: string, data?: Record<string, unknown>) => void;
25
+ };
26
+ redirectRoute?: string;
27
+ successMessage?: string;
28
+ };
29
+
30
+ export type UseLoginFormResult = {
31
+ values: LoginFormValues;
32
+ errors: LoginFormErrors;
33
+ passwordVisibility: PasswordVisibilityState;
34
+ isSubmitDisabled: boolean;
35
+ emailTouched: boolean;
36
+ isSuccess: boolean;
37
+ handleFieldChange: (fieldId: LoginFieldId, value: string) => void;
38
+ handleEmailBlur: () => void;
39
+ togglePasswordVisibility: () => void;
40
+ handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
41
+ handleCancel: () => void;
42
+ };
43
+
44
+ // section: helpers
45
+ const buildInitialValues = (): LoginFormValues => ({
46
+ [LOGIN_FIELD_IDS.EMAIL]: "",
47
+ [LOGIN_FIELD_IDS.PASSWORD]: "",
48
+ });
49
+
50
+ const get_filename = (): string => {
51
+ return "use_login_form.ts";
52
+ };
53
+
54
+ const get_line_number = (): number => {
55
+ // This is a placeholder - in a real implementation, you might use Error stack trace
56
+ return 0;
57
+ };
58
+
59
+ // section: hook
60
+ export const use_login_form = <TClient,>({
61
+ dataClient,
62
+ logger,
63
+ redirectRoute,
64
+ successMessage = "Successfully logged in",
65
+ }: UseLoginFormParams<TClient>): UseLoginFormResult => {
66
+ const router = useRouter();
67
+ const [values, setValues] = useState<LoginFormValues>(buildInitialValues);
68
+ const [errors, setErrors] = useState<LoginFormErrors>({});
69
+ const [passwordVisibility, setPasswordVisibility] = useState<PasswordVisibilityState>({
70
+ password: false,
71
+ });
72
+ const [clientIp, setClientIp] = useState<string>("unknown");
73
+ const [emailTouched, setEmailTouched] = useState<boolean>(false);
74
+ const [isSuccess, setIsSuccess] = useState<boolean>(false);
75
+
76
+ // section: ip_collection
77
+ useEffect(() => {
78
+ let isMounted = true;
79
+ void get_client_ip().then((ip) => {
80
+ if (isMounted) {
81
+ setClientIp(ip);
82
+ }
83
+ });
84
+ return () => {
85
+ isMounted = false;
86
+ };
87
+ }, []);
88
+
89
+ const isSubmitDisabled = useMemo(() => {
90
+ const hasEmptyField = Object.values(values).some((fieldValue) => fieldValue.trim() === "");
91
+ const hasErrors = Object.keys(errors).length > 0;
92
+ return hasEmptyField || hasErrors;
93
+ }, [errors, values]);
94
+
95
+ const togglePasswordVisibility = useCallback(() => {
96
+ setPasswordVisibility((previous) => ({
97
+ password: !previous.password,
98
+ }));
99
+ }, []);
100
+
101
+ const handleFieldChange = useCallback((fieldId: LoginFieldId, value: string) => {
102
+ setValues((previousValues) => {
103
+ const nextValues: LoginFormValues = {
104
+ ...previousValues,
105
+ [fieldId]: value,
106
+ };
107
+
108
+ setErrors((previousErrors) => {
109
+ const updatedErrors: LoginFormErrors = { ...previousErrors };
110
+
111
+ // Only validate email on change if it has been touched (blurred)
112
+ if (fieldId === LOGIN_FIELD_IDS.EMAIL && emailTouched) {
113
+ const emailError = validateEmail(value);
114
+ if (emailError) {
115
+ updatedErrors[LOGIN_FIELD_IDS.EMAIL] = emailError;
116
+ } else {
117
+ delete updatedErrors[LOGIN_FIELD_IDS.EMAIL];
118
+ }
119
+ }
120
+
121
+ return updatedErrors;
122
+ });
123
+
124
+ return nextValues;
125
+ });
126
+ }, [emailTouched]);
127
+
128
+ const handleEmailBlur = useCallback(() => {
129
+ setEmailTouched(true);
130
+ // Validate email on blur
131
+ setErrors((previousErrors) => {
132
+ const updatedErrors: LoginFormErrors = { ...previousErrors };
133
+ const emailValue = values[LOGIN_FIELD_IDS.EMAIL];
134
+ const emailError = validateEmail(emailValue);
135
+ if (emailError) {
136
+ updatedErrors[LOGIN_FIELD_IDS.EMAIL] = emailError;
137
+ } else {
138
+ delete updatedErrors[LOGIN_FIELD_IDS.EMAIL];
139
+ }
140
+ return updatedErrors;
141
+ });
142
+ }, [values]);
143
+
144
+ // section: login_attempt_logging
145
+ const log_login_attempt = useCallback(
146
+ (success: boolean, errorMessage?: string) => {
147
+ if (!logger) {
148
+ return;
149
+ }
150
+
151
+ const timestamp = new Date().toISOString();
152
+ const logData = {
153
+ filename: get_filename(),
154
+ line_number: get_line_number(),
155
+ email: values[LOGIN_FIELD_IDS.EMAIL],
156
+ ip_address: clientIp,
157
+ timestamp,
158
+ success,
159
+ ...(errorMessage ? { error_message: errorMessage } : {}),
160
+ };
161
+
162
+ if (success) {
163
+ logger.info("login_attempt_successful", logData);
164
+ } else {
165
+ logger.error("login_attempt_failed", logData);
166
+ }
167
+ },
168
+ [logger, values, clientIp],
169
+ );
170
+
171
+ const handleSubmit = useCallback(
172
+ async (event: React.FormEvent<HTMLFormElement>) => {
173
+ event.preventDefault();
174
+
175
+ const email = values[LOGIN_FIELD_IDS.EMAIL];
176
+ const password = values[LOGIN_FIELD_IDS.PASSWORD];
177
+
178
+ try {
179
+ // Update IP address if still unknown
180
+ const currentIp = clientIp === "unknown" ? await get_client_ip() : clientIp;
181
+ setClientIp(currentIp);
182
+
183
+ // Attempt login via API route
184
+ const response = await fetch("/api/auth/login", {
185
+ method: "POST",
186
+ headers: {
187
+ "Content-Type": "application/json",
188
+ },
189
+ body: JSON.stringify({
190
+ email,
191
+ password,
192
+ }),
193
+ });
194
+
195
+ const data = await response.json();
196
+
197
+ if (!response.ok || !data.success) {
198
+ // Check if email is not verified
199
+ if (data.email_not_verified) {
200
+ // Redirect to verify_email page with email and message
201
+ const emailParam = encodeURIComponent(email);
202
+ const messageParam = encodeURIComponent(
203
+ "Your email address has not been verified. Please verify your email to continue."
204
+ );
205
+ router.push(`/verify_email?email=${emailParam}&message=${messageParam}`);
206
+ return;
207
+ }
208
+
209
+ // Login failed for other reasons
210
+ const errorMessage = data.error || "Login failed. Please try again.";
211
+
212
+ // Log failed login attempt
213
+ log_login_attempt(false, errorMessage);
214
+
215
+ // Set error state (remain on same page)
216
+ setErrors({
217
+ [LOGIN_FIELD_IDS.EMAIL]: errorMessage,
218
+ });
219
+ setIsSuccess(false);
220
+ return;
221
+ }
222
+
223
+ // Login successful
224
+ // Log successful login attempt
225
+ log_login_attempt(true);
226
+
227
+ // Trigger auth status refresh in all components (navbar, sidebar, etc.)
228
+ trigger_auth_status_refresh();
229
+
230
+ // Refresh the page to update authentication state (cookies are set server-side)
231
+ router.refresh();
232
+
233
+ // If redirect route is provided, redirect to it
234
+ if (redirectRoute) {
235
+ router.push(redirectRoute);
236
+ } else {
237
+ // Otherwise, show success message
238
+ setIsSuccess(true);
239
+ }
240
+ } catch (error) {
241
+ const errorMessage =
242
+ error instanceof Error ? error.message : "Unknown error occurred";
243
+
244
+ // Log failed login attempt
245
+ log_login_attempt(false, errorMessage);
246
+
247
+ // Set error state (remain on same page)
248
+ setErrors({
249
+ [LOGIN_FIELD_IDS.EMAIL]: errorMessage,
250
+ });
251
+ setIsSuccess(false);
252
+ }
253
+ },
254
+ [values, clientIp, log_login_attempt, redirectRoute, router],
255
+ );
256
+
257
+ const handleCancel = useCallback(() => {
258
+ setValues(buildInitialValues());
259
+ setErrors({});
260
+ setPasswordVisibility({
261
+ password: false,
262
+ });
263
+ setEmailTouched(false);
264
+ setIsSuccess(false);
265
+ }, []);
266
+
267
+ return {
268
+ values,
269
+ errors,
270
+ passwordVisibility,
271
+ isSubmitDisabled,
272
+ emailTouched,
273
+ isSuccess,
274
+ handleFieldChange,
275
+ handleEmailBlur,
276
+ togglePasswordVisibility,
277
+ handleSubmit,
278
+ handleCancel,
279
+ };
280
+ };
281
+
@@ -0,0 +1,224 @@
1
+ // file_description: login layout component built atop shared layout utilities
2
+ // section: client_directive
3
+ "use client";
4
+
5
+ // section: imports
6
+ import { Input } from "@/components/ui/input";
7
+ import { PasswordField } from "@/components/layouts/shared/components/password_field";
8
+ import { FormFieldWrapper } from "@/components/layouts/shared/components/form_field_wrapper";
9
+ import { FormHeader } from "@/components/layouts/shared/components/form_header";
10
+ import { FormActionButtons } from "@/components/layouts/shared/components/form_action_buttons";
11
+ import { TwoColumnAuthLayout } from "@/components/layouts/shared/components/two_column_auth_layout";
12
+ import { CheckCircle } from "lucide-react";
13
+ import { AlreadyLoggedInGuard } from "@/components/layouts/shared/components/already_logged_in_guard";
14
+ import {
15
+ type ButtonPaletteOverrides,
16
+ type LayoutFieldMapOverrides,
17
+ type LayoutLabelOverrides,
18
+ } from "@/components/layouts/shared/config/layout_customization";
19
+ import {
20
+ LOGIN_FIELD_IDS,
21
+ createLoginFieldDefinitions,
22
+ resolveLoginButtonPalette,
23
+ resolveLoginLabels,
24
+ } from "@/components/layouts/login/config/login_field_config";
25
+ import {
26
+ use_login_form,
27
+ type UseLoginFormResult,
28
+ } from "@/components/layouts/login/hooks/use_login_form";
29
+ import { type LayoutDataClient } from "@/components/layouts/shared/data/layout_data_client";
30
+
31
+ // section: types
32
+ export type LoginLayoutProps<TClient = unknown> = {
33
+ image_src: string;
34
+ image_alt: string;
35
+ image_background_color?: string;
36
+ field_overrides?: LayoutFieldMapOverrides;
37
+ labels?: LayoutLabelOverrides;
38
+ button_colors?: ButtonPaletteOverrides;
39
+ data_client: LayoutDataClient<TClient>;
40
+ logger?: {
41
+ info: (message: string, data?: Record<string, unknown>) => void;
42
+ error: (message: string, data?: Record<string, unknown>) => void;
43
+ warn: (message: string, data?: Record<string, unknown>) => void;
44
+ debug: (message: string, data?: Record<string, unknown>) => void;
45
+ };
46
+ redirectRoute?: string;
47
+ successMessage?: string;
48
+ alreadyLoggedInMessage?: string;
49
+ showLogoutButton?: boolean;
50
+ showReturnHomeButton?: boolean;
51
+ returnHomeButtonLabel?: string;
52
+ returnHomePath?: string;
53
+ };
54
+
55
+ const ORDERED_FIELDS: LoginFieldId[] = [
56
+ LOGIN_FIELD_IDS.EMAIL,
57
+ LOGIN_FIELD_IDS.PASSWORD,
58
+ ];
59
+
60
+ type LoginFieldId = (typeof LOGIN_FIELD_IDS)[keyof typeof LOGIN_FIELD_IDS];
61
+
62
+ // section: component
63
+ export default function login_layout<TClient>({
64
+ image_src,
65
+ image_alt,
66
+ image_background_color = "#f1f5f9",
67
+ field_overrides,
68
+ labels,
69
+ button_colors,
70
+ data_client,
71
+ logger,
72
+ redirectRoute,
73
+ successMessage = "Successfully logged in",
74
+ alreadyLoggedInMessage = "You are already logged in",
75
+ showLogoutButton = true,
76
+ showReturnHomeButton = false,
77
+ returnHomeButtonLabel = "Return home",
78
+ returnHomePath = "/",
79
+ }: LoginLayoutProps<TClient>) {
80
+ const fieldDefinitions = createLoginFieldDefinitions(field_overrides);
81
+ const resolvedLabels = resolveLoginLabels(labels);
82
+ const resolvedButtonPalette = resolveLoginButtonPalette(button_colors);
83
+
84
+ const form = use_login_form({
85
+ dataClient: data_client,
86
+ logger,
87
+ redirectRoute,
88
+ successMessage,
89
+ });
90
+
91
+ const renderFields = (formState: UseLoginFormResult) => {
92
+ return ORDERED_FIELDS.map((fieldId) => {
93
+ const fieldDefinition = fieldDefinitions[fieldId];
94
+ const fieldValue = formState.values[fieldId];
95
+ const fieldError = formState.errors[fieldId];
96
+
97
+ const isPasswordField = fieldDefinition.type === "password";
98
+
99
+ const inputElement = isPasswordField ? (
100
+ <PasswordField
101
+ inputId={fieldDefinition.id}
102
+ ariaLabel={fieldDefinition.ariaLabel}
103
+ value={fieldValue}
104
+ placeholder={fieldDefinition.placeholder}
105
+ autoComplete={fieldDefinition.autoComplete}
106
+ isVisible={formState.passwordVisibility.password}
107
+ onChange={(nextValue) => formState.handleFieldChange(fieldId, nextValue)}
108
+ onToggleVisibility={formState.togglePasswordVisibility}
109
+ errorMessage={fieldError}
110
+ />
111
+ ) : (
112
+ <Input
113
+ id={fieldDefinition.id}
114
+ type={fieldDefinition.type}
115
+ value={fieldValue}
116
+ onChange={(event) =>
117
+ formState.handleFieldChange(fieldId, event.target.value)
118
+ }
119
+ onBlur={
120
+ fieldId === LOGIN_FIELD_IDS.EMAIL
121
+ ? formState.handleEmailBlur
122
+ : undefined
123
+ }
124
+ autoComplete={fieldDefinition.autoComplete}
125
+ placeholder={fieldDefinition.placeholder}
126
+ aria-label={fieldDefinition.ariaLabel}
127
+ className="cls_login_layout_field_input"
128
+ />
129
+ );
130
+
131
+ // Only show email error if field has been touched (blurred)
132
+ const shouldShowError =
133
+ isPasswordField
134
+ ? undefined
135
+ : fieldId === LOGIN_FIELD_IDS.EMAIL
136
+ ? formState.emailTouched && fieldError
137
+ ? fieldError
138
+ : undefined
139
+ : fieldError;
140
+
141
+ return (
142
+ <FormFieldWrapper
143
+ key={fieldId}
144
+ fieldId={fieldDefinition.id}
145
+ label={fieldDefinition.label}
146
+ input={inputElement}
147
+ errorMessage={shouldShowError}
148
+ />
149
+ );
150
+ });
151
+ };
152
+
153
+ // Show success message if login was successful and no redirect route is provided
154
+ if (form.isSuccess) {
155
+ return (
156
+ <TwoColumnAuthLayout
157
+ imageSrc={image_src}
158
+ imageAlt={image_alt}
159
+ imageBackgroundColor={image_background_color}
160
+ formContent={
161
+ <>
162
+ <FormHeader
163
+ heading={resolvedLabels.heading}
164
+ subHeading={resolvedLabels.subHeading}
165
+ />
166
+ <div className="cls_login_layout_success flex flex-col items-center justify-center gap-4 p-8 text-center">
167
+ <CheckCircle
168
+ className="cls_login_layout_success_icon h-16 w-16 text-green-600"
169
+ aria-hidden="true"
170
+ />
171
+ <p className="cls_login_layout_success_message text-lg font-medium text-slate-900">
172
+ {successMessage}
173
+ </p>
174
+ </div>
175
+ </>
176
+ }
177
+ />
178
+ );
179
+ }
180
+
181
+ return (
182
+ <AlreadyLoggedInGuard
183
+ image_src={image_src}
184
+ image_alt={image_alt}
185
+ image_background_color={image_background_color}
186
+ message={alreadyLoggedInMessage}
187
+ showLogoutButton={showLogoutButton}
188
+ showReturnHomeButton={showReturnHomeButton}
189
+ returnHomeButtonLabel={returnHomeButtonLabel}
190
+ returnHomePath={returnHomePath}
191
+ >
192
+ <TwoColumnAuthLayout
193
+ imageSrc={image_src}
194
+ imageAlt={image_alt}
195
+ imageBackgroundColor={image_background_color}
196
+ formContent={
197
+ <>
198
+ <FormHeader
199
+ heading={resolvedLabels.heading}
200
+ subHeading={resolvedLabels.subHeading}
201
+ />
202
+ <form
203
+ className="cls_login_layout_form_fields flex flex-col gap-5"
204
+ onSubmit={form.handleSubmit}
205
+ aria-label="Login form"
206
+ >
207
+ {renderFields(form)}
208
+ <FormActionButtons
209
+ submitLabel={resolvedLabels.submitButton}
210
+ cancelLabel={resolvedLabels.cancelButton}
211
+ buttonPalette={resolvedButtonPalette}
212
+ isSubmitDisabled={form.isSubmitDisabled}
213
+ onCancel={form.handleCancel}
214
+ submitAriaLabel="Submit login form"
215
+ cancelAriaLabel="Cancel login form"
216
+ />
217
+ </form>
218
+ </>
219
+ }
220
+ />
221
+ </AlreadyLoggedInGuard>
222
+ );
223
+ }
224
+