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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/components.json +22 -0
- package/hazo_auth_config.example.ini +414 -0
- package/hazo_notify_config.example.ini +159 -0
- package/instrumentation.ts +32 -0
- package/migrations/001_add_token_type_to_refresh_tokens.sql +14 -0
- package/migrations/002_add_name_to_hazo_users.sql +7 -0
- package/next.config.mjs +55 -0
- package/package.json +114 -0
- package/postcss.config.mjs +8 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/apply_migration.ts +118 -0
- package/src/app/api/auth/change_password/route.ts +109 -0
- package/src/app/api/auth/forgot_password/route.ts +107 -0
- package/src/app/api/auth/library_photos/route.ts +70 -0
- package/src/app/api/auth/login/route.ts +155 -0
- package/src/app/api/auth/logout/route.ts +62 -0
- package/src/app/api/auth/me/route.ts +47 -0
- package/src/app/api/auth/profile_picture/[filename]/route.ts +67 -0
- package/src/app/api/auth/register/route.ts +106 -0
- package/src/app/api/auth/remove_profile_picture/route.ts +86 -0
- package/src/app/api/auth/resend_verification/route.ts +107 -0
- package/src/app/api/auth/reset_password/route.ts +107 -0
- package/src/app/api/auth/update_user/route.ts +126 -0
- package/src/app/api/auth/upload_profile_picture/route.ts +268 -0
- package/src/app/api/auth/validate_reset_token/route.ts +80 -0
- package/src/app/api/auth/verify_email/route.ts +85 -0
- package/src/app/api/migrations/apply/route.ts +91 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/fonts/GeistMonoVF.woff +0 -0
- package/src/app/fonts/GeistVF.woff +0 -0
- package/src/app/forgot_password/forgot_password_page_client.tsx +60 -0
- package/src/app/forgot_password/page.tsx +24 -0
- package/src/app/globals.css +89 -0
- package/src/app/hazo_connect/api/sqlite/data/route.ts +197 -0
- package/src/app/hazo_connect/api/sqlite/schema/route.ts +35 -0
- package/src/app/hazo_connect/api/sqlite/tables/route.ts +26 -0
- package/src/app/hazo_connect/sqlite_admin/page.tsx +51 -0
- package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +947 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/login/login_page_client.tsx +71 -0
- package/src/app/login/page.tsx +26 -0
- package/src/app/my_settings/my_settings_page_client.tsx +120 -0
- package/src/app/my_settings/page.tsx +40 -0
- package/src/app/page.tsx +170 -0
- package/src/app/register/page.tsx +26 -0
- package/src/app/register/register_page_client.tsx +72 -0
- package/src/app/reset_password/page.tsx +29 -0
- package/src/app/reset_password/reset_password_page_client.tsx +81 -0
- package/src/app/verify_email/page.tsx +24 -0
- package/src/app/verify_email/verify_email_page_client.tsx +60 -0
- package/src/components/layouts/email_verification/config/email_verification_field_config.ts +86 -0
- package/src/components/layouts/email_verification/hooks/use_email_verification.ts +291 -0
- package/src/components/layouts/email_verification/index.tsx +297 -0
- package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +58 -0
- package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +179 -0
- package/src/components/layouts/forgot_password/index.tsx +168 -0
- package/src/components/layouts/login/config/login_field_config.ts +67 -0
- package/src/components/layouts/login/hooks/use_login_form.ts +281 -0
- package/src/components/layouts/login/index.tsx +224 -0
- package/src/components/layouts/my_settings/components/editable_field.tsx +177 -0
- package/src/components/layouts/my_settings/components/password_change_dialog.tsx +301 -0
- package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +385 -0
- package/src/components/layouts/my_settings/components/profile_picture_display.tsx +66 -0
- package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +143 -0
- package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +282 -0
- package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +341 -0
- package/src/components/layouts/my_settings/config/my_settings_field_config.ts +61 -0
- package/src/components/layouts/my_settings/hooks/use_my_settings.ts +458 -0
- package/src/components/layouts/my_settings/index.tsx +351 -0
- package/src/components/layouts/register/config/register_field_config.ts +101 -0
- package/src/components/layouts/register/hooks/use_register_form.ts +272 -0
- package/src/components/layouts/register/index.tsx +208 -0
- package/src/components/layouts/reset_password/config/reset_password_field_config.ts +86 -0
- package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +276 -0
- package/src/components/layouts/reset_password/index.tsx +294 -0
- package/src/components/layouts/shared/components/already_logged_in_guard.tsx +95 -0
- package/src/components/layouts/shared/components/field_error_message.tsx +29 -0
- package/src/components/layouts/shared/components/form_action_buttons.tsx +64 -0
- package/src/components/layouts/shared/components/form_field_wrapper.tsx +44 -0
- package/src/components/layouts/shared/components/form_header.tsx +36 -0
- package/src/components/layouts/shared/components/logout_button.tsx +76 -0
- package/src/components/layouts/shared/components/password_field.tsx +72 -0
- package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +264 -0
- package/src/components/layouts/shared/components/two_column_auth_layout.tsx +44 -0
- package/src/components/layouts/shared/components/unauthorized_guard.tsx +78 -0
- package/src/components/layouts/shared/components/visual_panel.tsx +41 -0
- package/src/components/layouts/shared/config/layout_customization.ts +95 -0
- package/src/components/layouts/shared/data/layout_data_client.ts +19 -0
- package/src/components/layouts/shared/hooks/use_auth_status.ts +103 -0
- package/src/components/layouts/shared/utils/ip_address.ts +37 -0
- package/src/components/layouts/shared/utils/validation.ts +66 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/hazo_ui_tooltip.tsx +67 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +139 -0
- package/src/components/ui/sidebar.tsx +773 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +31 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/ui/vertical-tabs.tsx +59 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/lib/already_logged_in_config.server.ts +46 -0
- package/src/lib/app_logger.ts +24 -0
- package/src/lib/auth/auth_utils.server.ts +196 -0
- package/src/lib/auth/server_auth.ts +88 -0
- package/src/lib/config/config_loader.server.ts +149 -0
- package/src/lib/email_verification_config.server.ts +32 -0
- package/src/lib/file_types_config.server.ts +25 -0
- package/src/lib/forgot_password_config.server.ts +32 -0
- package/src/lib/hazo_connect_instance.server.ts +77 -0
- package/src/lib/hazo_connect_setup.server.ts +181 -0
- package/src/lib/hazo_connect_setup.ts +54 -0
- package/src/lib/login_config.server.ts +46 -0
- package/src/lib/messages_config.server.ts +45 -0
- package/src/lib/migrations/apply_migration.ts +105 -0
- package/src/lib/my_settings_config.server.ts +135 -0
- package/src/lib/password_requirements_config.server.ts +39 -0
- package/src/lib/profile_picture_config.server.ts +56 -0
- package/src/lib/register_config.server.ts +57 -0
- package/src/lib/reset_password_config.server.ts +75 -0
- package/src/lib/services/email_service.ts +581 -0
- package/src/lib/services/email_verification_service.ts +264 -0
- package/src/lib/services/login_service.ts +118 -0
- package/src/lib/services/password_change_service.ts +154 -0
- package/src/lib/services/password_reset_service.ts +405 -0
- package/src/lib/services/profile_picture_remove_service.ts +120 -0
- package/src/lib/services/profile_picture_service.ts +215 -0
- package/src/lib/services/profile_picture_source_mapper.ts +62 -0
- package/src/lib/services/registration_service.ts +163 -0
- package/src/lib/services/token_service.ts +240 -0
- package/src/lib/services/user_update_service.ts +128 -0
- package/src/lib/ui_sizes_config.server.ts +37 -0
- package/src/lib/user_fields_config.server.ts +31 -0
- package/src/lib/utils/api_route_helpers.ts +60 -0
- package/src/lib/utils.ts +11 -0
- package/src/middleware.ts +91 -0
- package/src/server/config/config_loader.ts +496 -0
- package/src/server/index.ts +38 -0
- package/src/server/logging/logger_service.ts +56 -0
- package/src/server/routes/root_router.ts +16 -0
- package/src/server/server.ts +28 -0
- package/src/server/types/app_types.ts +74 -0
- package/src/server/types/express.d.ts +15 -0
- package/src/stories/email_verification_layout.stories.tsx +137 -0
- package/src/stories/forgot_password_layout.stories.tsx +85 -0
- package/src/stories/login_layout.stories.tsx +85 -0
- package/src/stories/project_overview.stories.tsx +33 -0
- package/src/stories/register_layout.stories.tsx +107 -0
- package/tailwind.config.ts +77 -0
- 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
|
+
|