@salesforce/ui-bundle-template-feature-react-authentication 1.117.2
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.txt +82 -0
- package/README.md +77 -0
- package/dist/.forceignore +15 -0
- package/dist/.husky/pre-commit +4 -0
- package/dist/.prettierignore +11 -0
- package/dist/.prettierrc +17 -0
- package/dist/AGENT.md +193 -0
- package/dist/CHANGELOG.md +2128 -0
- package/dist/README.md +28 -0
- package/dist/config/project-scratch-def.json +13 -0
- package/dist/eslint.config.js +7 -0
- package/dist/force-app/main/default/classes/UIBundleAuthUtils.cls +68 -0
- package/dist/force-app/main/default/classes/UIBundleAuthUtils.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/UIBundleChangePassword.cls +77 -0
- package/dist/force-app/main/default/classes/UIBundleChangePassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/UIBundleForgotPassword.cls +71 -0
- package/dist/force-app/main/default/classes/UIBundleForgotPassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/UIBundleLogin.cls +105 -0
- package/dist/force-app/main/default/classes/UIBundleLogin.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/UIBundleRegistration.cls +162 -0
- package/dist/force-app/main/default/classes/UIBundleRegistration.cls-meta.xml +5 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/.forceignore +15 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/.graphqlrc.yml +2 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/.prettierignore +9 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/.prettierrc +11 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/CHANGELOG.md +10 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/README.md +75 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/codegen.yml +95 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/components.json +18 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/e2e/app.spec.ts +17 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/eslint.config.js +169 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/feature-react-authentication.uibundle-meta.xml +7 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/index.html +12 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/package.json +70 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/playwright.config.ts +24 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/scripts/get-graphql-schema.mjs +68 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/scripts/rewrite-e2e-assets.mjs +23 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/api/graphqlClient.ts +25 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/app.tsx +16 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/appLayout.tsx +83 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/book.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/copy.svg +4 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/rocket.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/star.svg +3 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-1.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-2.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-3.png +0 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/vibe-codey.svg +194 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/alerts/status-alert.tsx +49 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/layouts/card-layout.tsx +29 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/alert.tsx +76 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/badge.tsx +48 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/breadcrumb.tsx +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/button.tsx +67 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/calendar.tsx +232 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/card.tsx +103 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/checkbox.tsx +32 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/collapsible.tsx +33 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/datePicker.tsx +127 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/dialog.tsx +162 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/field.tsx +237 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/index.ts +84 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/input.tsx +19 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/label.tsx +22 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/pagination.tsx +132 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/popover.tsx +89 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/select.tsx +193 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/separator.tsx +26 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/skeleton.tsx +14 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/sonner.tsx +20 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/spinner.tsx +16 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/table.tsx +114 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/tabs.tsx +88 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/api/userProfileApi.ts +95 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/authHelpers.ts +73 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/authenticationConfig.ts +61 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/context/AuthContext.tsx +95 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/footers/footer-link.tsx +36 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/forms/auth-form.tsx +81 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/forms/submit-button.tsx +49 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/form.tsx +120 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/useCountdownTimer.ts +266 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/useRetryWithBackoff.ts +109 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layout/card-skeleton.tsx +38 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layout/centered-page-layout.tsx +87 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/AuthAppLayout.tsx +12 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/authenticationRouteLayout.tsx +21 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/privateRouteLayout.tsx +44 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ChangePassword.tsx +107 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ForgotPassword.tsx +73 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Login.tsx +97 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Profile.tsx +161 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Register.tsx +133 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ResetPassword.tsx +107 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +602 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/sessionTimeService.ts +149 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/sessionTimeoutConfig.ts +77 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/utils/helpers.ts +121 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/lib/utils.ts +6 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/navigationMenu.tsx +80 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/pages/Home.tsx +12 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/pages/NotFound.tsx +18 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/router-utils.tsx +35 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/routes.tsx +71 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/styles/global.css +135 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/tsconfig.json +42 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/tsconfig.node.json +13 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/ui-bundle.json +7 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/vite-env.d.ts +1 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/vite.config.ts +106 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest-env.d.ts +2 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest.config.ts +11 -0
- package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest.setup.ts +1 -0
- package/dist/jest.config.js +6 -0
- package/dist/package-lock.json +9995 -0
- package/dist/package.json +40 -0
- package/dist/scripts/apex/hello.apex +10 -0
- package/dist/scripts/graphql-search.sh +191 -0
- package/dist/scripts/prepare-import-unique-fields.js +122 -0
- package/dist/scripts/setup-cli.mjs +563 -0
- package/dist/scripts/sf-project-setup.mjs +66 -0
- package/dist/scripts/soql/account.soql +6 -0
- package/dist/sfdx-project.json +12 -0
- package/package.json +44 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensible user profile fetching and updating via UI API GraphQL.
|
|
3
|
+
*/
|
|
4
|
+
import { createDataSDK } from "@salesforce/sdk-data";
|
|
5
|
+
import { flattenGraphQLRecord } from "../utils/helpers";
|
|
6
|
+
|
|
7
|
+
const USER_PROFILE_FIELDS_FULL = `
|
|
8
|
+
Id
|
|
9
|
+
FirstName @optional { value }
|
|
10
|
+
LastName @optional { value }
|
|
11
|
+
Email @optional { value }
|
|
12
|
+
Phone @optional { value }
|
|
13
|
+
Street @optional { value }
|
|
14
|
+
City @optional { value }
|
|
15
|
+
State @optional { value }
|
|
16
|
+
PostalCode @optional { value }
|
|
17
|
+
Country @optional { value }`;
|
|
18
|
+
|
|
19
|
+
const USER_CONTACT_FIELDS = `
|
|
20
|
+
Id
|
|
21
|
+
ContactId @optional { value }`;
|
|
22
|
+
|
|
23
|
+
function getUserProfileQuery(fields: string): string {
|
|
24
|
+
return `
|
|
25
|
+
query GetUserProfile($userId: ID) {
|
|
26
|
+
uiapi {
|
|
27
|
+
query {
|
|
28
|
+
User(where: { Id: { eq: $userId } }) {
|
|
29
|
+
edges {
|
|
30
|
+
node {${fields}}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getUserProfileMutation(fields: string): string {
|
|
39
|
+
return `
|
|
40
|
+
mutation UpdateUserProfile($input: UserUpdateInput!) {
|
|
41
|
+
uiapi {
|
|
42
|
+
UserUpdate(input: $input) {
|
|
43
|
+
Record {${fields}}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function throwOnGraphQLErrors(response: any): void {
|
|
50
|
+
if (response?.errors?.length) {
|
|
51
|
+
throw new Error(response.errors.map((e: any) => e.message).join("; "));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fetches the user profile via GraphQL and returns a flattened record.
|
|
57
|
+
* @param userId - The Salesforce User Id.
|
|
58
|
+
* @param fields - GraphQL field selection (defaults to USER_PROFILE_FIELDS_FULL).
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchUserProfile<T>(
|
|
61
|
+
userId: string,
|
|
62
|
+
fields: string = USER_PROFILE_FIELDS_FULL,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const data = await createDataSDK();
|
|
65
|
+
const response: any = await data.graphql?.(getUserProfileQuery(fields), {
|
|
66
|
+
userId,
|
|
67
|
+
});
|
|
68
|
+
throwOnGraphQLErrors(response);
|
|
69
|
+
return flattenGraphQLRecord<T>(response?.data?.uiapi?.query?.User?.edges?.[0]?.node);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetches the user's associated contact record ID via GraphQL and returns a flattened record.
|
|
74
|
+
* @param userId - The Salesforce User Id.
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchUserContact<T>(userId: string): Promise<T> {
|
|
77
|
+
return fetchUserProfile<T>(userId, USER_CONTACT_FIELDS);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Updates the user profile via GraphQL and returns the flattened updated record.
|
|
82
|
+
* @param userId - The Salesforce User Id.
|
|
83
|
+
* @param values - The field values to update.
|
|
84
|
+
*/
|
|
85
|
+
export async function updateUserProfile<T>(
|
|
86
|
+
userId: string,
|
|
87
|
+
values: Record<string, unknown>,
|
|
88
|
+
): Promise<T> {
|
|
89
|
+
const data = await createDataSDK();
|
|
90
|
+
const response: any = await data.graphql?.(getUserProfileMutation(USER_PROFILE_FIELDS_FULL), {
|
|
91
|
+
input: { Id: userId, User: { ...values } },
|
|
92
|
+
});
|
|
93
|
+
throwOnGraphQLErrors(response);
|
|
94
|
+
return flattenGraphQLRecord<T>(response?.data?.uiapi?.UserUpdate?.Record);
|
|
95
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { AUTH_REDIRECT_PARAM } from "./authenticationConfig";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** Email field validation */
|
|
5
|
+
export const emailSchema = z.string().trim().email("Please enter a valid email address");
|
|
6
|
+
|
|
7
|
+
/** Password field validation (minimum 8 characters) */
|
|
8
|
+
export const passwordSchema = z.string().min(8, "Password must be at least 8 characters");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared schema for new password + confirmation fields.
|
|
12
|
+
* Validates password length and matching confirmation.
|
|
13
|
+
*/
|
|
14
|
+
export const newPasswordSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
newPassword: passwordSchema,
|
|
17
|
+
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
18
|
+
})
|
|
19
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
20
|
+
message: "Passwords do not match",
|
|
21
|
+
path: ["confirmPassword"],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
*
|
|
26
|
+
* Extracts the startUrl from URLSearchParams, defaulting to '/'.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY NOTE: This function strictly validates the URL to prevent
|
|
29
|
+
* Open Redirect vulnerabilities. It allows only relative paths.
|
|
30
|
+
*
|
|
31
|
+
* @param searchParams - The URLSearchParams object from useSearchParams()
|
|
32
|
+
* @returns The start URL for post-authentication redirect
|
|
33
|
+
*/
|
|
34
|
+
export function getStartUrl(searchParams: URLSearchParams): string {
|
|
35
|
+
// 1. Check for the standard redirect parameter
|
|
36
|
+
const url = searchParams.get(AUTH_REDIRECT_PARAM);
|
|
37
|
+
// 2. Security Check: Validation Logic
|
|
38
|
+
if (url && isValidRedirect(url)) {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
// 3. Fallback: Default to root
|
|
42
|
+
return "/";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* [Dev Note] Security: Validates that the redirect URL is a relative path
|
|
47
|
+
* to prevent Open Redirect vulnerabilities.
|
|
48
|
+
*
|
|
49
|
+
* Security Checks:
|
|
50
|
+
* 1. Rejects protocol-relative URLs (//)
|
|
51
|
+
* 2. Rejects backslash usage which some browsers treat as slashes (/\)
|
|
52
|
+
* 3. Rejects control characters
|
|
53
|
+
*/
|
|
54
|
+
function isValidRedirect(url: string): boolean {
|
|
55
|
+
// Basic structure check
|
|
56
|
+
if (!url.startsWith("/") || url.startsWith("//")) return false;
|
|
57
|
+
// Security: Reject backslashes to prevent /\example.com bypasses
|
|
58
|
+
if (url.includes("\\")) return false;
|
|
59
|
+
// Robustness: Ensure it doesn't contain whitespace/control characters
|
|
60
|
+
if (/[^\u0021-\u00ff]/.test(url)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shared response type for authentication endpoints (login/register).
|
|
66
|
+
* Success responses contain `success: true` and `redirectUrl`.
|
|
67
|
+
* Error responses contain `errors` array.
|
|
68
|
+
*/
|
|
69
|
+
export interface AuthResponse {
|
|
70
|
+
success?: boolean;
|
|
71
|
+
redirectUrl?: string | null;
|
|
72
|
+
errors?: string[];
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [Dev Note] Centralized configuration for Auth routes.
|
|
3
|
+
* Each route contains both the path and page title.
|
|
4
|
+
* Using constants prevents typos in route paths across the application.
|
|
5
|
+
*/
|
|
6
|
+
export const ROUTES = {
|
|
7
|
+
LOGIN: {
|
|
8
|
+
PATH: "/login",
|
|
9
|
+
TITLE: "Login | MyApp",
|
|
10
|
+
},
|
|
11
|
+
REGISTER: {
|
|
12
|
+
PATH: "/register",
|
|
13
|
+
TITLE: "Create Account | MyApp",
|
|
14
|
+
},
|
|
15
|
+
FORGOT_PASSWORD: {
|
|
16
|
+
PATH: "/forgot-password",
|
|
17
|
+
TITLE: "Recover Password | MyApp",
|
|
18
|
+
},
|
|
19
|
+
RESET_PASSWORD: {
|
|
20
|
+
PATH: "/reset-password",
|
|
21
|
+
TITLE: "Reset Password | MyApp",
|
|
22
|
+
},
|
|
23
|
+
PROFILE: {
|
|
24
|
+
PATH: "/profile",
|
|
25
|
+
TITLE: "My Profile | MyApp",
|
|
26
|
+
},
|
|
27
|
+
CHANGE_PASSWORD: {
|
|
28
|
+
PATH: "/change-password",
|
|
29
|
+
TITLE: "Change Password | MyApp",
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* [Dev Note] Centralized configuration for API endpoints.
|
|
35
|
+
* These are server-side endpoints, not client-side routes.
|
|
36
|
+
*/
|
|
37
|
+
export const API_ROUTES = {
|
|
38
|
+
// W-21253864: Logout URL integration is not currently supported
|
|
39
|
+
LOGOUT: "/secur/logout.jsp",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* [Dev Note] Query parameter key used to store the return URL.
|
|
44
|
+
* e.g. /login?startUrl=/profile
|
|
45
|
+
*/
|
|
46
|
+
export const AUTH_REDIRECT_PARAM = "startUrl";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Placeholder text constants for authentication form inputs.
|
|
50
|
+
*/
|
|
51
|
+
export const AUTH_PLACEHOLDERS = {
|
|
52
|
+
EMAIL: "asalesforce@example.com",
|
|
53
|
+
PASSWORD: "",
|
|
54
|
+
PASSWORD_CREATE: "",
|
|
55
|
+
PASSWORD_CONFIRM: "",
|
|
56
|
+
PASSWORD_NEW: "",
|
|
57
|
+
PASSWORD_NEW_CONFIRM: "",
|
|
58
|
+
FIRST_NAME: "Astro",
|
|
59
|
+
LAST_NAME: "Salesforce",
|
|
60
|
+
USERNAME: "asalesforce",
|
|
61
|
+
} as const;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
|
2
|
+
import { getCurrentUser } from "@salesforce/ui-bundle/api";
|
|
3
|
+
import { API_ROUTES } from "../authenticationConfig";
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface AuthContextType {
|
|
11
|
+
user: User | null;
|
|
12
|
+
isAuthenticated: boolean;
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
checkAuth: () => Promise<void>;
|
|
16
|
+
logout: (startURL?: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
interface AuthProviderProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AuthProvider({ children }: AuthProviderProps) {
|
|
26
|
+
const [user, setUser] = useState<User | null>(null);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const checkAuth = useCallback(async () => {
|
|
31
|
+
setLoading(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const userData = await getCurrentUser();
|
|
36
|
+
setUser(userData);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const errorMessage = err instanceof Error ? err.message : "Authentication failed";
|
|
39
|
+
setError(errorMessage);
|
|
40
|
+
setUser(null);
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const logout = useCallback((startURL?: string) => {
|
|
47
|
+
// Navigate to logout URL (server-side endpoint)
|
|
48
|
+
// Use replace to prevent back button from returning to authenticated session
|
|
49
|
+
const finalLogoutUrl = startURL
|
|
50
|
+
? `${API_ROUTES.LOGOUT}?startURL=${encodeURIComponent(startURL)}`
|
|
51
|
+
: API_ROUTES.LOGOUT;
|
|
52
|
+
window.location.replace(finalLogoutUrl);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
checkAuth();
|
|
57
|
+
}, [checkAuth]);
|
|
58
|
+
|
|
59
|
+
const value: AuthContextType = {
|
|
60
|
+
user,
|
|
61
|
+
isAuthenticated: user !== null,
|
|
62
|
+
loading,
|
|
63
|
+
error,
|
|
64
|
+
checkAuth,
|
|
65
|
+
logout,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hook to access the authentication context.
|
|
73
|
+
* @returns {AuthContextType} Authentication state (user, isAuthenticated, loading, error, checkAuth)
|
|
74
|
+
* @throws {Error} If used outside of an AuthProvider
|
|
75
|
+
*/
|
|
76
|
+
export function useAuth(): AuthContextType {
|
|
77
|
+
const context = useContext(AuthContext);
|
|
78
|
+
if (context === undefined) {
|
|
79
|
+
throw new Error("useAuth must be used within an AuthProvider");
|
|
80
|
+
}
|
|
81
|
+
return context;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the current authenticated user.
|
|
86
|
+
* @returns {User} The authenticated user object
|
|
87
|
+
* @throws {Error} If not used within AuthProvider or user is not authenticated
|
|
88
|
+
*/
|
|
89
|
+
export function useUser(): User {
|
|
90
|
+
const context = useAuth();
|
|
91
|
+
if (!context.user) {
|
|
92
|
+
throw new Error("Authenticated context not established");
|
|
93
|
+
}
|
|
94
|
+
return context.user;
|
|
95
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
import { cn } from "../../../lib/utils";
|
|
3
|
+
|
|
4
|
+
interface FooterLinkProps extends Omit<React.ComponentProps<typeof Link>, "children"> {
|
|
5
|
+
/** Link text prefix (e.g., "Don't have an account?") */
|
|
6
|
+
text?: string;
|
|
7
|
+
/** Link label (e.g., "Sign up") */
|
|
8
|
+
linkText: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Footer link component.
|
|
13
|
+
*/
|
|
14
|
+
export function FooterLink({ text, to, linkText, className, ...props }: FooterLinkProps) {
|
|
15
|
+
return (
|
|
16
|
+
<p className={cn("w-full text-center text-sm text-muted-foreground", className)}>
|
|
17
|
+
{text && (
|
|
18
|
+
<>
|
|
19
|
+
{text}
|
|
20
|
+
{/* Robustness: Explicit space ensures formatting tools don't strip it */}
|
|
21
|
+
{"\u00A0"}
|
|
22
|
+
</>
|
|
23
|
+
)}
|
|
24
|
+
<Link
|
|
25
|
+
to={to}
|
|
26
|
+
className={cn(
|
|
27
|
+
"font-medium underline hover:text-primary transition-colors",
|
|
28
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{linkText}
|
|
33
|
+
</Link>
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { FieldGroup } from "../../../components/ui/field";
|
|
2
|
+
import { StatusAlert } from "../../../components/alerts/status-alert";
|
|
3
|
+
import { FooterLink } from "../footers/footer-link";
|
|
4
|
+
import { SubmitButton } from "./submit-button";
|
|
5
|
+
import { CardLayout } from "../../../components/layouts/card-layout";
|
|
6
|
+
import { useFormContext } from "../hooks/form";
|
|
7
|
+
import { useId } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* [Dev Note] A wrapper component that enforces consistent layout (Card) and error/success alert positioning
|
|
11
|
+
* for all authentication forms.
|
|
12
|
+
*/
|
|
13
|
+
interface AuthFormProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
|
|
14
|
+
title: string;
|
|
15
|
+
description: string;
|
|
16
|
+
error?: React.ReactNode;
|
|
17
|
+
success?: React.ReactNode;
|
|
18
|
+
submit: {
|
|
19
|
+
text: string;
|
|
20
|
+
loadingText?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
};
|
|
23
|
+
footer?: {
|
|
24
|
+
text?: string;
|
|
25
|
+
link: string;
|
|
26
|
+
linkText: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* [Dev Note] Standardized Authentication Layout:
|
|
32
|
+
* Wraps the specific logic of Login/Register forms with a consistent visual frame (Card),
|
|
33
|
+
* title, and error alert placement. Extends form element props for flexibility.
|
|
34
|
+
* This ensures all auth-related pages look and behave similarly.
|
|
35
|
+
*/
|
|
36
|
+
export function AuthForm({
|
|
37
|
+
id: providedId,
|
|
38
|
+
title,
|
|
39
|
+
description,
|
|
40
|
+
error,
|
|
41
|
+
success,
|
|
42
|
+
children,
|
|
43
|
+
submit,
|
|
44
|
+
footer,
|
|
45
|
+
...props
|
|
46
|
+
}: AuthFormProps) {
|
|
47
|
+
const form = useFormContext();
|
|
48
|
+
const generatedId = useId();
|
|
49
|
+
const id = providedId ?? generatedId;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<CardLayout title={title} description={description}>
|
|
53
|
+
<div className="space-y-6">
|
|
54
|
+
{/* [Dev Note] Global form error alert (e.g. "Invalid Credentials") */}
|
|
55
|
+
{error && <StatusAlert variant="error">{error}</StatusAlert>}
|
|
56
|
+
{success && <StatusAlert variant="success">{success}</StatusAlert>}
|
|
57
|
+
|
|
58
|
+
<form
|
|
59
|
+
id={id}
|
|
60
|
+
onSubmit={(e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
form.handleSubmit();
|
|
64
|
+
}}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
<FieldGroup>{children}</FieldGroup>
|
|
68
|
+
<SubmitButton
|
|
69
|
+
form={id}
|
|
70
|
+
label={submit.text}
|
|
71
|
+
loadingLabel={submit.loadingText}
|
|
72
|
+
disabled={submit.disabled}
|
|
73
|
+
className="mt-6"
|
|
74
|
+
/>
|
|
75
|
+
</form>
|
|
76
|
+
{/* [Dev Note] Navigation links (e.g. "Forgot Password?") */}
|
|
77
|
+
{footer && <FooterLink text={footer.text} to={footer.link} linkText={footer.linkText} />}
|
|
78
|
+
</div>
|
|
79
|
+
</CardLayout>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Button } from "../../../components/ui/button";
|
|
2
|
+
import { Spinner } from "../../../components/ui/spinner";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { useFormContext } from "../hooks/form";
|
|
5
|
+
|
|
6
|
+
interface SubmitButtonProps extends Omit<React.ComponentProps<typeof Button>, "type"> {
|
|
7
|
+
/** Button text when not submitting */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Button text while submitting */
|
|
10
|
+
loadingLabel?: string;
|
|
11
|
+
/** Form id to associate with (for buttons outside form element) */
|
|
12
|
+
form?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isSubmittingSelector = (state: { isSubmitting: boolean }) => state.isSubmitting;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Submit button that subscribes to form submission state.
|
|
19
|
+
* UX Best Practice:
|
|
20
|
+
* 1. Disables interaction immediately upon click (via isLoading) to prevent
|
|
21
|
+
* accidental double-submissions.
|
|
22
|
+
* 2. Provides immediate visual feedback (Spinner).
|
|
23
|
+
*/
|
|
24
|
+
export function SubmitButton({
|
|
25
|
+
label,
|
|
26
|
+
loadingLabel = "Submitting…",
|
|
27
|
+
className,
|
|
28
|
+
form: formId,
|
|
29
|
+
disabled,
|
|
30
|
+
...props
|
|
31
|
+
}: SubmitButtonProps) {
|
|
32
|
+
const form = useFormContext();
|
|
33
|
+
return (
|
|
34
|
+
<form.Subscribe selector={isSubmittingSelector}>
|
|
35
|
+
{(isSubmitting: boolean) => (
|
|
36
|
+
<Button
|
|
37
|
+
type="submit"
|
|
38
|
+
form={formId}
|
|
39
|
+
className={cn("w-full", className)}
|
|
40
|
+
disabled={isSubmitting || disabled}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{isSubmitting && <Spinner className="mr-2" aria-hidden="true" />}
|
|
44
|
+
{isSubmitting ? loadingLabel : label}
|
|
45
|
+
</Button>
|
|
46
|
+
)}
|
|
47
|
+
</form.Subscribe>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { createFormHookContexts, createFormHook } from "@tanstack/react-form";
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldLabel,
|
|
8
|
+
} from "../../../components/ui/field";
|
|
9
|
+
import { Input } from "../../../components/ui/input";
|
|
10
|
+
import { cn } from "../../../lib/utils";
|
|
11
|
+
import { AUTH_PLACEHOLDERS } from "../authenticationConfig";
|
|
12
|
+
|
|
13
|
+
// Create form hook contexts
|
|
14
|
+
export const { fieldContext, formContext, useFieldContext, useFormContext } =
|
|
15
|
+
createFormHookContexts();
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Field Components
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
interface TextFieldProps extends Omit<
|
|
22
|
+
React.ComponentProps<typeof Input>,
|
|
23
|
+
"name" | "value" | "onBlur" | "onChange" | "aria-invalid"
|
|
24
|
+
> {
|
|
25
|
+
label: string;
|
|
26
|
+
labelAction?: React.ReactNode;
|
|
27
|
+
description?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TextField({
|
|
31
|
+
label,
|
|
32
|
+
id: providedId,
|
|
33
|
+
labelAction,
|
|
34
|
+
description,
|
|
35
|
+
type = "text",
|
|
36
|
+
...props
|
|
37
|
+
}: TextFieldProps) {
|
|
38
|
+
const field = useFieldContext<string>();
|
|
39
|
+
const generatedId = useId();
|
|
40
|
+
const id = providedId ?? generatedId;
|
|
41
|
+
const descriptionId = `${id}-description`;
|
|
42
|
+
const errorId = `${id}-error`;
|
|
43
|
+
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
|
44
|
+
|
|
45
|
+
// Deduplicate errors by message
|
|
46
|
+
const errors = field.state.meta.errors;
|
|
47
|
+
const uniqueErrors = [...new Map(errors.map((item: any) => [item["message"], item])).values()];
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Field data-invalid={isInvalid}>
|
|
51
|
+
<div className="flex items-center">
|
|
52
|
+
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
53
|
+
{labelAction && <div className="ml-auto">{labelAction}</div>}
|
|
54
|
+
</div>
|
|
55
|
+
{description && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
|
|
56
|
+
<Input
|
|
57
|
+
id={id}
|
|
58
|
+
name={field.name as string}
|
|
59
|
+
type={type}
|
|
60
|
+
value={field.state.value ?? ""}
|
|
61
|
+
onBlur={field.handleBlur}
|
|
62
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => field.handleChange(e.target.value)}
|
|
63
|
+
aria-invalid={isInvalid}
|
|
64
|
+
aria-describedby={cn(description && descriptionId, isInvalid && errorId)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
{isInvalid && uniqueErrors.length > 0 && <FieldError errors={uniqueErrors} />}
|
|
68
|
+
</Field>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Password field with preset type and autocomplete */
|
|
73
|
+
function PasswordField({
|
|
74
|
+
label,
|
|
75
|
+
autoComplete = "current-password",
|
|
76
|
+
placeholder = AUTH_PLACEHOLDERS.PASSWORD,
|
|
77
|
+
...props
|
|
78
|
+
}: Omit<TextFieldProps, "type">) {
|
|
79
|
+
return (
|
|
80
|
+
<TextField
|
|
81
|
+
label={label}
|
|
82
|
+
type="password"
|
|
83
|
+
autoComplete={autoComplete}
|
|
84
|
+
placeholder={placeholder}
|
|
85
|
+
{...props}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Email field with preset type and autocomplete */
|
|
91
|
+
function EmailField({
|
|
92
|
+
label,
|
|
93
|
+
placeholder = AUTH_PLACEHOLDERS.EMAIL,
|
|
94
|
+
...props
|
|
95
|
+
}: Omit<TextFieldProps, "type">) {
|
|
96
|
+
return (
|
|
97
|
+
<TextField
|
|
98
|
+
label={label}
|
|
99
|
+
type="email"
|
|
100
|
+
autoComplete="username"
|
|
101
|
+
placeholder={placeholder}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Create Form Hook
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
export const { useAppForm } = createFormHook({
|
|
112
|
+
fieldContext,
|
|
113
|
+
formContext,
|
|
114
|
+
fieldComponents: {
|
|
115
|
+
TextField,
|
|
116
|
+
PasswordField,
|
|
117
|
+
EmailField,
|
|
118
|
+
},
|
|
119
|
+
formComponents: {},
|
|
120
|
+
});
|