@salesforce/webapp-template-app-react-sample-b2x-experimental 1.79.2 → 1.80.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/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls +68 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppChangePassword.cls +77 -0
- package/dist/force-app/main/default/classes/WebAppChangePassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppForgotPassword.cls +71 -0
- package/dist/force-app/main/default/classes/WebAppForgotPassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppLogin.cls +105 -0
- package/dist/force-app/main/default/classes/WebAppLogin.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls +162 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls-meta.xml +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +15 -6
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/app.tsx +4 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +155 -88
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/api/userProfileApi.ts +81 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authHelpers.ts +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authenticationConfig.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/context/AuthContext.tsx +95 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/footers/footer-link.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/auth-form.tsx +81 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/submit-button.tsx +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/form.tsx +120 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useCountdownTimer.ts +266 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useRetryWithBackoff.ts +109 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/card-skeleton.tsx +38 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/centered-page-layout.tsx +87 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/AuthAppLayout.tsx +12 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/authenticationRouteLayout.tsx +21 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/privateRouteLayout.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ChangePassword.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ForgotPassword.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Login.tsx +97 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Profile.tsx +139 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Register.tsx +133 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ResetPassword.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +616 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeService.ts +161 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeoutConfig.ts +77 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/utils/helpers.ts +121 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +201 -114
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +67 -13
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "../../../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Variant styles for the content container's maximum width.
|
|
6
|
+
* Controls the maximum width of the inner content area within the page layout.
|
|
7
|
+
*/
|
|
8
|
+
const contentContainerVariants = cva("w-full", {
|
|
9
|
+
variants: {
|
|
10
|
+
contentMaxWidth: {
|
|
11
|
+
sm: "max-w-sm",
|
|
12
|
+
md: "max-w-md",
|
|
13
|
+
lg: "max-w-lg",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
contentMaxWidth: "sm",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Props for the CenteredPageLayout component.
|
|
23
|
+
*/
|
|
24
|
+
interface CenteredPageLayoutProps
|
|
25
|
+
extends React.ComponentProps<"div">, VariantProps<typeof contentContainerVariants> {
|
|
26
|
+
/** The content to be displayed within the page layout */
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Maximum width of the content container.
|
|
30
|
+
* @default "sm"
|
|
31
|
+
*/
|
|
32
|
+
contentMaxWidth?: "sm" | "md" | "lg";
|
|
33
|
+
/**
|
|
34
|
+
* Optional page title. If provided, will render a <title> component that React will place in the document head.
|
|
35
|
+
*/
|
|
36
|
+
title?: string;
|
|
37
|
+
/**
|
|
38
|
+
* When true, content is aligned to the top instead of being vertically centered.
|
|
39
|
+
* @default true
|
|
40
|
+
*/
|
|
41
|
+
topAligned?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CenteredPageLayout component that provides consistent page structure and spacing.
|
|
46
|
+
*
|
|
47
|
+
* This component creates a full-viewport-height container that centers its content
|
|
48
|
+
* horizontally. By default, content is top-aligned; set `topAligned={false}` to
|
|
49
|
+
* vertically center instead. The inner content area has a configurable maximum width
|
|
50
|
+
* to prevent content from becoming too wide on large screens.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* <CenteredPageLayout contentMaxWidth="md">
|
|
55
|
+
* <YourPageContent />
|
|
56
|
+
* </CenteredPageLayout>
|
|
57
|
+
*
|
|
58
|
+
* <CenteredPageLayout contentMaxWidth="md" topAligned={false}>
|
|
59
|
+
* <VerticallyCenteredContent />
|
|
60
|
+
* </CenteredPageLayout>
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function CenteredPageLayout({
|
|
64
|
+
contentMaxWidth,
|
|
65
|
+
className,
|
|
66
|
+
children,
|
|
67
|
+
title,
|
|
68
|
+
topAligned = true,
|
|
69
|
+
...props
|
|
70
|
+
}: CenteredPageLayoutProps) {
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{title && <title>{title}</title>}
|
|
74
|
+
<main
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex min-h-svh w-full justify-center p-6 md:p-10",
|
|
77
|
+
topAligned ? "items-start" : "items-center",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
data-slot="page-layout"
|
|
81
|
+
{...props}
|
|
82
|
+
>
|
|
83
|
+
<div className={contentContainerVariants({ contentMaxWidth })}>{children}</div>
|
|
84
|
+
</main>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import SessionTimeoutValidator from "../sessionTimeout/SessionTimeoutValidator";
|
|
2
|
+
import { AuthProvider } from "../context/AuthContext";
|
|
3
|
+
import AppLayout from "../../../appLayout";
|
|
4
|
+
|
|
5
|
+
export default function AuthAppLayout() {
|
|
6
|
+
return (
|
|
7
|
+
<AuthProvider>
|
|
8
|
+
<SessionTimeoutValidator basePath="" />
|
|
9
|
+
<AppLayout />
|
|
10
|
+
</AuthProvider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Navigate, Outlet, useSearchParams } from "react-router";
|
|
2
|
+
import { useAuth } from "../context/AuthContext";
|
|
3
|
+
import { getStartUrl } from "../authHelpers";
|
|
4
|
+
import { CardSkeleton } from "../layout/card-skeleton";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* [Dev Note] "Public Only" Route Guard:
|
|
8
|
+
* This component protects routes that should NOT be accessible if the user is already logged in
|
|
9
|
+
* (e.g., Login, Register, Forgot Password).
|
|
10
|
+
* If an authenticated user tries to access these pages, they are automatically redirected
|
|
11
|
+
* to the default authenticated view (e.g., Home or Profile) to prevent confusion.
|
|
12
|
+
*/
|
|
13
|
+
export default function AuthenticationRoute() {
|
|
14
|
+
const { isAuthenticated, loading } = useAuth();
|
|
15
|
+
const [searchParams] = useSearchParams();
|
|
16
|
+
|
|
17
|
+
if (loading) return <CardSkeleton contentMaxWidth="md" />;
|
|
18
|
+
if (isAuthenticated) return <Navigate to={getStartUrl(searchParams)} replace />;
|
|
19
|
+
|
|
20
|
+
return <Outlet />;
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Navigate, Outlet, useLocation } from "react-router";
|
|
2
|
+
import { useAuth } from "../context/AuthContext";
|
|
3
|
+
import { AUTH_REDIRECT_PARAM, ROUTES } from "../authenticationConfig";
|
|
4
|
+
import { CardSkeleton } from "../layout/card-skeleton";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* [Dev Note] Route Guard:
|
|
8
|
+
* Renders the child route (Outlet) if the user is authenticated.
|
|
9
|
+
* Otherwise, redirects to Login with a 'startUrl' parameter so the user can be
|
|
10
|
+
* returned to this page after successful login.
|
|
11
|
+
*/
|
|
12
|
+
export default function PrivateRoute() {
|
|
13
|
+
const { isAuthenticated, loading } = useAuth();
|
|
14
|
+
const location = useLocation();
|
|
15
|
+
|
|
16
|
+
if (loading) return <CardSkeleton contentMaxWidth="md" />;
|
|
17
|
+
|
|
18
|
+
if (!isAuthenticated) {
|
|
19
|
+
const searchParams = new URLSearchParams();
|
|
20
|
+
|
|
21
|
+
// [Dev Note] Capture current location to return after login
|
|
22
|
+
const destination = location.pathname + location.search;
|
|
23
|
+
searchParams.set(AUTH_REDIRECT_PARAM, destination);
|
|
24
|
+
return (
|
|
25
|
+
<Navigate // Navigate accepts an object to safely construct the URL
|
|
26
|
+
to={{
|
|
27
|
+
pathname: ROUTES.LOGIN.PATH,
|
|
28
|
+
search: searchParams.toString(),
|
|
29
|
+
}}
|
|
30
|
+
replace
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return <Outlet />;
|
|
36
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CenteredPageLayout } from "../layout/centered-page-layout";
|
|
5
|
+
import { AuthForm } from "../forms/auth-form";
|
|
6
|
+
import { useAppForm } from "../hooks/form";
|
|
7
|
+
import { getDataSDK } from "@salesforce/sdk-data";
|
|
8
|
+
import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
|
|
9
|
+
import { newPasswordSchema } from "../authHelpers";
|
|
10
|
+
import { handleApiResponse, getErrorMessage } from "../utils/helpers";
|
|
11
|
+
|
|
12
|
+
const changePasswordSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
currentPassword: z.string().min(1, "Current password is required"),
|
|
15
|
+
})
|
|
16
|
+
.and(newPasswordSchema);
|
|
17
|
+
|
|
18
|
+
export default function ChangePassword() {
|
|
19
|
+
const [success, setSuccess] = useState(false);
|
|
20
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
const form = useAppForm({
|
|
23
|
+
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
|
|
24
|
+
validators: { onChange: changePasswordSchema, onSubmit: changePasswordSchema },
|
|
25
|
+
onSubmit: async ({ value: formFieldValues }) => {
|
|
26
|
+
setSubmitError(null);
|
|
27
|
+
setSuccess(false);
|
|
28
|
+
try {
|
|
29
|
+
// [Dev Note] Custom Apex Endpoint: /auth/change-password
|
|
30
|
+
// You must ensure this Apex class exists in your org
|
|
31
|
+
const sdk = await getDataSDK();
|
|
32
|
+
const response = await sdk.fetch!("/sfdcapi/services/apexrest/auth/change-password", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
currentPassword: formFieldValues.currentPassword,
|
|
36
|
+
newPassword: formFieldValues.newPassword,
|
|
37
|
+
}),
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
await handleApiResponse(response, "Password change failed");
|
|
44
|
+
setSuccess(true);
|
|
45
|
+
form.reset();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setSubmitError(getErrorMessage(err, "Password change failed"));
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
onSubmitInvalid: () => {},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<CenteredPageLayout title={ROUTES.CHANGE_PASSWORD.TITLE}>
|
|
55
|
+
<form.AppForm>
|
|
56
|
+
<AuthForm
|
|
57
|
+
title="Change Password"
|
|
58
|
+
description="Enter your current and new password below"
|
|
59
|
+
error={submitError}
|
|
60
|
+
success={
|
|
61
|
+
success && (
|
|
62
|
+
<>
|
|
63
|
+
Password changed successfully!{" "}
|
|
64
|
+
<Link to={ROUTES.PROFILE.PATH} className="underline">
|
|
65
|
+
Back to Profile
|
|
66
|
+
</Link>
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
submit={{ text: "Change Password", loadingText: "Changing…", disabled: success }}
|
|
71
|
+
footer={{ link: ROUTES.PROFILE.PATH, linkText: "Back to Profile" }}
|
|
72
|
+
>
|
|
73
|
+
<form.AppField name="currentPassword">
|
|
74
|
+
{(field) => (
|
|
75
|
+
<field.PasswordField
|
|
76
|
+
label="Current Password"
|
|
77
|
+
placeholder={AUTH_PLACEHOLDERS.PASSWORD}
|
|
78
|
+
autoComplete="current-password"
|
|
79
|
+
disabled={success}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</form.AppField>
|
|
83
|
+
<form.AppField name="newPassword">
|
|
84
|
+
{(field) => (
|
|
85
|
+
<field.PasswordField
|
|
86
|
+
label="New Password"
|
|
87
|
+
placeholder={AUTH_PLACEHOLDERS.PASSWORD_NEW}
|
|
88
|
+
autoComplete="new-password"
|
|
89
|
+
disabled={success}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</form.AppField>
|
|
93
|
+
<form.AppField name="confirmPassword">
|
|
94
|
+
{(field) => (
|
|
95
|
+
<field.PasswordField
|
|
96
|
+
label="Confirm Password"
|
|
97
|
+
placeholder={AUTH_PLACEHOLDERS.PASSWORD_NEW_CONFIRM}
|
|
98
|
+
autoComplete="new-password"
|
|
99
|
+
disabled={success}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
</form.AppField>
|
|
103
|
+
</AuthForm>
|
|
104
|
+
</form.AppForm>
|
|
105
|
+
</CenteredPageLayout>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CenteredPageLayout } from "../layout/centered-page-layout";
|
|
4
|
+
import { AuthForm } from "../forms/auth-form";
|
|
5
|
+
import { useAppForm } from "../hooks/form";
|
|
6
|
+
import { getDataSDK } from "@salesforce/sdk-data";
|
|
7
|
+
import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
|
|
8
|
+
import { handleApiResponse, getErrorMessage } from "../utils/helpers";
|
|
9
|
+
|
|
10
|
+
const forgotPasswordSchema = z.object({
|
|
11
|
+
username: z.string().trim().toLowerCase().email("Please enter a valid username"),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export default function ForgotPassword() {
|
|
15
|
+
const [success, setSuccess] = useState(false);
|
|
16
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
const form = useAppForm({
|
|
19
|
+
defaultValues: { username: "" },
|
|
20
|
+
validators: { onChange: forgotPasswordSchema, onSubmit: forgotPasswordSchema },
|
|
21
|
+
onSubmit: async ({ value }) => {
|
|
22
|
+
setSubmitError(null);
|
|
23
|
+
setSuccess(false);
|
|
24
|
+
try {
|
|
25
|
+
// [Dev Note] Custom Apex Endpoint: /auth/forgot-password
|
|
26
|
+
// You must ensure this Apex class exists in your org
|
|
27
|
+
const sdk = await getDataSDK();
|
|
28
|
+
const response = await sdk.fetch!("/sfdcapi/services/apexrest/auth/forgot-password", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: JSON.stringify({ username: value.username.trim() }),
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
Accept: "application/json",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
await handleApiResponse(response, "Failed to send reset link");
|
|
37
|
+
setSuccess(true);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setSubmitError(getErrorMessage(err, "Failed to send reset link"));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
onSubmitInvalid: () => {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<CenteredPageLayout title={ROUTES.FORGOT_PASSWORD.TITLE}>
|
|
47
|
+
<form.AppForm>
|
|
48
|
+
<AuthForm
|
|
49
|
+
title="Forgot Password"
|
|
50
|
+
description="Enter your username and we'll send you a reset link"
|
|
51
|
+
error={submitError}
|
|
52
|
+
success={
|
|
53
|
+
success &&
|
|
54
|
+
"If that username exists in our system, you will receive a reset link shortly."
|
|
55
|
+
}
|
|
56
|
+
submit={{ text: "Send Reset Link", loadingText: "Sending…", disabled: success }}
|
|
57
|
+
footer={{ text: "Remember your password?", link: ROUTES.LOGIN.PATH, linkText: "Sign in" }}
|
|
58
|
+
>
|
|
59
|
+
<form.AppField name="username">
|
|
60
|
+
{(field) => (
|
|
61
|
+
<field.TextField
|
|
62
|
+
label="Username"
|
|
63
|
+
placeholder={AUTH_PLACEHOLDERS.USERNAME}
|
|
64
|
+
autoComplete="username"
|
|
65
|
+
disabled={success}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
</form.AppField>
|
|
69
|
+
</AuthForm>
|
|
70
|
+
</form.AppForm>
|
|
71
|
+
</CenteredPageLayout>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate, useSearchParams, Link } from "react-router";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CenteredPageLayout } from "../layout/centered-page-layout";
|
|
5
|
+
import { AuthForm } from "../forms/auth-form";
|
|
6
|
+
import { useAppForm } from "../hooks/form";
|
|
7
|
+
import { getDataSDK } from "@salesforce/sdk-data";
|
|
8
|
+
import { ROUTES } from "../authenticationConfig";
|
|
9
|
+
import { emailSchema, getStartUrl, type AuthResponse } from "../authHelpers";
|
|
10
|
+
import { handleApiResponse, getErrorMessage } from "../utils/helpers";
|
|
11
|
+
|
|
12
|
+
const loginSchema = z.object({
|
|
13
|
+
email: emailSchema,
|
|
14
|
+
password: z.string().min(1, "Password is required"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export default function Login() {
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const [searchParams] = useSearchParams();
|
|
20
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
const form = useAppForm({
|
|
23
|
+
defaultValues: { email: "", password: "" },
|
|
24
|
+
validators: { onChange: loginSchema, onSubmit: loginSchema },
|
|
25
|
+
onSubmit: async ({ value }) => {
|
|
26
|
+
setSubmitError(null);
|
|
27
|
+
try {
|
|
28
|
+
// [Dev Note] Salesforce Integration:
|
|
29
|
+
// We use the Data SDK fetch to make an authenticated (or guest) call to Salesforce.
|
|
30
|
+
// "/sfdcapi/services/apexrest/auth/login" refers to a custom Apex REST resource.
|
|
31
|
+
// You must ensure this Apex class exists in your org and handles the login logic
|
|
32
|
+
// (e.g., creating a session or returning a token).
|
|
33
|
+
const sdk = await getDataSDK();
|
|
34
|
+
const response = await sdk.fetch!("/sfdcapi/services/apexrest/auth/login", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
email: value.email.trim().toLowerCase(),
|
|
38
|
+
password: value.password,
|
|
39
|
+
startUrl: getStartUrl(searchParams),
|
|
40
|
+
}),
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Accept: "application/json",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const result = await handleApiResponse<AuthResponse>(response, "Login failed");
|
|
47
|
+
if (result?.redirectUrl) {
|
|
48
|
+
// Hard navigate to the URL which establishes the server session cookie
|
|
49
|
+
window.location.replace(result.redirectUrl);
|
|
50
|
+
} else {
|
|
51
|
+
// In case redirectUrl is null, navigate to home
|
|
52
|
+
navigate("/", { replace: true });
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
setSubmitError(getErrorMessage(err, "Login failed"));
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
onSubmitInvalid: () => {},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<CenteredPageLayout title={ROUTES.LOGIN.TITLE}>
|
|
63
|
+
<form.AppForm>
|
|
64
|
+
<AuthForm
|
|
65
|
+
title="Login"
|
|
66
|
+
description="Enter your email below to login to your account"
|
|
67
|
+
error={submitError}
|
|
68
|
+
submit={{ text: "Login", loadingText: "Logging in…" }}
|
|
69
|
+
footer={{
|
|
70
|
+
text: "Don't have an account?",
|
|
71
|
+
link: ROUTES.REGISTER.PATH,
|
|
72
|
+
linkText: "Sign up",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<form.AppField name="email">
|
|
76
|
+
{(field) => <field.EmailField label="Email" />}
|
|
77
|
+
</form.AppField>
|
|
78
|
+
<form.AppField name="password">
|
|
79
|
+
{(field) => (
|
|
80
|
+
<field.PasswordField
|
|
81
|
+
label="Password"
|
|
82
|
+
labelAction={
|
|
83
|
+
<Link
|
|
84
|
+
to={ROUTES.FORGOT_PASSWORD.PATH}
|
|
85
|
+
className="text-sm underline-offset-4 hover:underline"
|
|
86
|
+
>
|
|
87
|
+
Forgot your password?
|
|
88
|
+
</Link>
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</form.AppField>
|
|
93
|
+
</AuthForm>
|
|
94
|
+
</form.AppForm>
|
|
95
|
+
</CenteredPageLayout>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { CenteredPageLayout } from "../layout/centered-page-layout";
|
|
5
|
+
import { CardSkeleton } from "../layout/card-skeleton";
|
|
6
|
+
import { AuthForm } from "../forms/auth-form";
|
|
7
|
+
import { useAppForm } from "../hooks/form";
|
|
8
|
+
import { ROUTES } from "../authenticationConfig";
|
|
9
|
+
import { emailSchema } from "../authHelpers";
|
|
10
|
+
import { getErrorMessage } from "../utils/helpers";
|
|
11
|
+
import { getUser } from "../context/AuthContext";
|
|
12
|
+
import { fetchUserProfile, updateUserProfile } from "../api/userProfileApi";
|
|
13
|
+
|
|
14
|
+
const optionalString = z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.transform((val) => (val === "" ? null : val))
|
|
18
|
+
.nullable()
|
|
19
|
+
.optional();
|
|
20
|
+
|
|
21
|
+
const profileSchema = z.object({
|
|
22
|
+
FirstName: z.string().trim().min(1, "First name is required"),
|
|
23
|
+
LastName: z.string().trim().min(1, "Last name is required"),
|
|
24
|
+
Email: emailSchema,
|
|
25
|
+
Phone: optionalString,
|
|
26
|
+
Street: optionalString,
|
|
27
|
+
City: optionalString,
|
|
28
|
+
State: optionalString,
|
|
29
|
+
PostalCode: optionalString,
|
|
30
|
+
Country: optionalString,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
type ProfileFormValues = z.infer<typeof profileSchema>;
|
|
34
|
+
|
|
35
|
+
export default function Profile() {
|
|
36
|
+
const user = getUser();
|
|
37
|
+
const [profile, setProfile] = useState<ProfileFormValues | null>(null);
|
|
38
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
39
|
+
const [success, setSuccess] = useState(false);
|
|
40
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
const form = useAppForm({
|
|
43
|
+
defaultValues: {} as ProfileFormValues,
|
|
44
|
+
validators: { onChange: profileSchema, onSubmit: profileSchema },
|
|
45
|
+
onSubmit: async ({ value }) => {
|
|
46
|
+
setSubmitError(null);
|
|
47
|
+
setSuccess(false);
|
|
48
|
+
try {
|
|
49
|
+
const updated = await updateUserProfile<ProfileFormValues>(user.id, value);
|
|
50
|
+
setProfile(updated);
|
|
51
|
+
setSuccess(true);
|
|
52
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setSubmitError(getErrorMessage(err, "Failed to update profile"));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
onSubmitInvalid: () => {},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let mounted = true;
|
|
62
|
+
fetchUserProfile<ProfileFormValues>(user.id)
|
|
63
|
+
.then((data) => {
|
|
64
|
+
if (mounted) {
|
|
65
|
+
setProfile(data);
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.catch((err: any) => {
|
|
69
|
+
if (mounted) {
|
|
70
|
+
setLoadError(getErrorMessage(err, "Failed to load profile"));
|
|
71
|
+
} else {
|
|
72
|
+
console.error("Failed to load profile", err);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return () => {
|
|
76
|
+
mounted = false;
|
|
77
|
+
};
|
|
78
|
+
}, [user.id]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (profile) {
|
|
82
|
+
const formData = profileSchema.parse(profile);
|
|
83
|
+
form.reset(formData);
|
|
84
|
+
}
|
|
85
|
+
}, [profile]);
|
|
86
|
+
|
|
87
|
+
if (!profile && !loadError) {
|
|
88
|
+
return <CardSkeleton contentMaxWidth="md" loadingText="Loading profile…" />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<CenteredPageLayout contentMaxWidth="md" title={ROUTES.PROFILE.TITLE}>
|
|
93
|
+
<form.AppForm>
|
|
94
|
+
<AuthForm
|
|
95
|
+
title="Profile"
|
|
96
|
+
description="Update your account information"
|
|
97
|
+
error={loadError ?? submitError}
|
|
98
|
+
success={success && "Profile updated!"}
|
|
99
|
+
submit={{ text: "Save Changes", loadingText: "Saving…" }}
|
|
100
|
+
footer={{ link: ROUTES.CHANGE_PASSWORD.PATH, linkText: "Change password" }}
|
|
101
|
+
>
|
|
102
|
+
<form.AppField name="Email">
|
|
103
|
+
{(field) => <field.EmailField label="Email" disabled />}
|
|
104
|
+
</form.AppField>
|
|
105
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
|
|
106
|
+
<form.AppField name="FirstName">
|
|
107
|
+
{(field) => <field.TextField label="First name" autoComplete="given-name" />}
|
|
108
|
+
</form.AppField>
|
|
109
|
+
<form.AppField name="LastName">
|
|
110
|
+
{(field) => <field.TextField label="Last name" autoComplete="family-name" />}
|
|
111
|
+
</form.AppField>
|
|
112
|
+
</div>
|
|
113
|
+
<form.AppField name="Phone">
|
|
114
|
+
{(field) => <field.TextField label="Phone" type="tel" autoComplete="tel" />}
|
|
115
|
+
</form.AppField>
|
|
116
|
+
<form.AppField name="Street">
|
|
117
|
+
{(field) => <field.TextField label="Street" autoComplete="street-address" />}
|
|
118
|
+
</form.AppField>
|
|
119
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
|
|
120
|
+
<form.AppField name="City">
|
|
121
|
+
{(field) => <field.TextField label="City" autoComplete="address-level2" />}
|
|
122
|
+
</form.AppField>
|
|
123
|
+
<form.AppField name="State">
|
|
124
|
+
{(field) => <field.TextField label="State" autoComplete="address-level1" />}
|
|
125
|
+
</form.AppField>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
|
|
128
|
+
<form.AppField name="PostalCode">
|
|
129
|
+
{(field) => <field.TextField label="Postal Code" autoComplete="postal-code" />}
|
|
130
|
+
</form.AppField>
|
|
131
|
+
<form.AppField name="Country">
|
|
132
|
+
{(field) => <field.TextField label="Country" autoComplete="country-name" />}
|
|
133
|
+
</form.AppField>
|
|
134
|
+
</div>
|
|
135
|
+
</AuthForm>
|
|
136
|
+
</form.AppForm>
|
|
137
|
+
</CenteredPageLayout>
|
|
138
|
+
);
|
|
139
|
+
}
|