@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.
Files changed (44) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/classes/WebAppAuthUtils.cls +68 -0
  3. package/dist/force-app/main/default/classes/WebAppAuthUtils.cls-meta.xml +5 -0
  4. package/dist/force-app/main/default/classes/WebAppChangePassword.cls +77 -0
  5. package/dist/force-app/main/default/classes/WebAppChangePassword.cls-meta.xml +5 -0
  6. package/dist/force-app/main/default/classes/WebAppForgotPassword.cls +71 -0
  7. package/dist/force-app/main/default/classes/WebAppForgotPassword.cls-meta.xml +5 -0
  8. package/dist/force-app/main/default/classes/WebAppLogin.cls +105 -0
  9. package/dist/force-app/main/default/classes/WebAppLogin.cls-meta.xml +5 -0
  10. package/dist/force-app/main/default/classes/WebAppRegistration.cls +162 -0
  11. package/dist/force-app/main/default/classes/WebAppRegistration.cls-meta.xml +5 -0
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +15 -6
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/app.tsx +4 -1
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +155 -88
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/api/userProfileApi.ts +81 -0
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authHelpers.ts +73 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authenticationConfig.ts +61 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/context/AuthContext.tsx +95 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/footers/footer-link.tsx +36 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/auth-form.tsx +81 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/submit-button.tsx +49 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/form.tsx +120 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useCountdownTimer.ts +266 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useRetryWithBackoff.ts +109 -0
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/card-skeleton.tsx +38 -0
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/centered-page-layout.tsx +87 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/AuthAppLayout.tsx +12 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/authenticationRouteLayout.tsx +21 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/privateRouteLayout.tsx +36 -0
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ChangePassword.tsx +107 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ForgotPassword.tsx +73 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Login.tsx +97 -0
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Profile.tsx +139 -0
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Register.tsx +133 -0
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ResetPassword.tsx +107 -0
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +616 -0
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeService.ts +161 -0
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeoutConfig.ts +77 -0
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/utils/helpers.ts +121 -0
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +201 -114
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +67 -13
  43. package/dist/package.json +1 -1
  44. package/package.json +1 -1
@@ -1,15 +1,148 @@
1
- import { useState } from "react";
2
- import { Outlet, NavLink } from "react-router";
1
+ import { useState, useRef, useEffect, type ComponentType } from "react";
2
+ import { Outlet, NavLink, useNavigate } from "react-router";
3
3
  import { Button } from "./components/ui/button";
4
4
  import { cn } from "./lib/utils";
5
- import { Home, Search, BarChart3, Wrench, Menu, Heart, Bell, Building2, Phone } from "lucide-react";
5
+ import { useAuth } from "./features/authentication/context/AuthContext";
6
+ import {
7
+ Home,
8
+ Search,
9
+ BarChart3,
10
+ Wrench,
11
+ Menu,
12
+ Heart,
13
+ Bell,
14
+ Building2,
15
+ Phone,
16
+ User,
17
+ LogOut,
18
+ ChevronDown,
19
+ } from "lucide-react";
6
20
 
7
21
  const SIDEBAR_WIDTH = 200;
8
22
  const FLOAT_INSET = 20;
9
23
  const FLOAT_GAP = 20;
10
24
 
11
- function AppShell() {
25
+ const HEADER_BUTTON =
26
+ "cursor-pointer text-primary-foreground transition-colors duration-200 hover:bg-primary-foreground/20";
27
+
28
+ interface SidebarLinkProps {
29
+ to: string;
30
+ label: string;
31
+ icon: ComponentType<{ className?: string }>;
32
+ end?: boolean;
33
+ }
34
+
35
+ function SidebarLink({ to, label, icon: Icon, end }: SidebarLinkProps) {
36
+ return (
37
+ <NavLink
38
+ to={to}
39
+ end={end}
40
+ className={({ isActive }) =>
41
+ cn(
42
+ "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
43
+ isActive && "bg-primary/15 text-primary font-medium",
44
+ )
45
+ }
46
+ aria-label={label}
47
+ >
48
+ <Icon className="size-[22px] shrink-0" aria-hidden />
49
+ <span className="text-sm font-medium">{label}</span>
50
+ </NavLink>
51
+ );
52
+ }
53
+
54
+ function UserMenu() {
55
+ const [open, setOpen] = useState(false);
56
+ const ref = useRef<HTMLDivElement>(null);
57
+ const navigate = useNavigate();
58
+ const { user, logout } = useAuth();
59
+
60
+ useEffect(() => {
61
+ if (!open) return;
62
+ function handleClickOutside(e: MouseEvent) {
63
+ if (ref.current && !ref.current.contains(e.target as Node)) {
64
+ setOpen(false);
65
+ }
66
+ }
67
+ document.addEventListener("mousedown", handleClickOutside);
68
+ return () => document.removeEventListener("mousedown", handleClickOutside);
69
+ }, [open]);
70
+
71
+ return (
72
+ <div className="relative" ref={ref}>
73
+ <button
74
+ type="button"
75
+ onClick={() => setOpen((o) => !o)}
76
+ className={cn(
77
+ "flex items-center gap-1.5 rounded-md border-none bg-transparent px-2 py-1.5 text-sm font-medium",
78
+ HEADER_BUTTON,
79
+ )}
80
+ aria-haspopup="true"
81
+ aria-expanded={open}
82
+ >
83
+ {user!.name}
84
+ <ChevronDown
85
+ className={cn("size-4 transition-transform duration-200", open && "rotate-180")}
86
+ aria-hidden
87
+ />
88
+ </button>
89
+ {open && (
90
+ <div
91
+ className="absolute right-0 top-full z-50 mt-1.5 w-44 overflow-hidden rounded-lg border border-border bg-card py-1 shadow-lg"
92
+ role="menu"
93
+ >
94
+ <button
95
+ type="button"
96
+ role="menuitem"
97
+ className="flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-3 py-2 text-left text-sm text-foreground transition-colors duration-150 hover:bg-muted"
98
+ onClick={() => {
99
+ setOpen(false);
100
+ navigate("/profile");
101
+ }}
102
+ >
103
+ <User className="size-4" aria-hidden />
104
+ Edit Profile
105
+ </button>
106
+ <button
107
+ type="button"
108
+ role="menuitem"
109
+ className="flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-3 py-2 text-left text-sm text-destructive transition-colors duration-150 hover:bg-destructive/10"
110
+ onClick={() => {
111
+ setOpen(false);
112
+ logout();
113
+ }}
114
+ >
115
+ <LogOut className="size-4" aria-hidden />
116
+ Log Out
117
+ </button>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function Sidebar({ isAuthenticated }: { isAuthenticated: boolean }) {
125
+ return (
126
+ <nav
127
+ className="absolute bottom-5 left-5 top-5 z-10 flex w-[200px] flex-col items-start gap-1 overflow-hidden rounded-2xl border border-border bg-card p-4 py-2 shadow-md"
128
+ aria-label="Main navigation"
129
+ >
130
+ <SidebarLink to="/" label="Home" icon={Home} end />
131
+ <SidebarLink to="/properties" label="Property Search" icon={Search} />
132
+ {isAuthenticated && (
133
+ <>
134
+ <SidebarLink to="/dashboard" label="Dashboard" icon={BarChart3} />
135
+ <SidebarLink to="/maintenance" label="Maintenance" icon={Wrench} />
136
+ </>
137
+ )}
138
+ <SidebarLink to="/contact" label="Contact" icon={Phone} />
139
+ </nav>
140
+ );
141
+ }
142
+
143
+ export default function AppLayout() {
12
144
  const [navHidden, setNavHidden] = useState(false);
145
+ const { isAuthenticated, loading, user } = useAuth();
13
146
 
14
147
  return (
15
148
  <div className="flex min-h-screen flex-col">
@@ -22,7 +155,7 @@ function AppShell() {
22
155
  type="button"
23
156
  variant="ghost"
24
157
  size="icon"
25
- className="min-h-11 min-w-11 cursor-pointer text-primary-foreground transition-colors duration-200 hover:bg-primary-foreground/20"
158
+ className={cn("min-h-11 min-w-11", HEADER_BUTTON)}
26
159
  aria-label={navHidden ? "Show menu" : "Hide menu"}
27
160
  onClick={() => setNavHidden((h) => !h)}
28
161
  >
@@ -38,7 +171,7 @@ function AppShell() {
38
171
  type="button"
39
172
  variant="ghost"
40
173
  size="icon"
41
- className="cursor-pointer text-primary-foreground transition-colors duration-200 hover:bg-primary-foreground/20"
174
+ className={HEADER_BUTTON}
42
175
  aria-label="Favorites"
43
176
  >
44
177
  <Heart className="size-5" aria-hidden />
@@ -47,91 +180,29 @@ function AppShell() {
47
180
  type="button"
48
181
  variant="ghost"
49
182
  size="icon"
50
- className="cursor-pointer text-primary-foreground transition-colors duration-200 hover:bg-primary-foreground/20"
183
+ className={HEADER_BUTTON}
51
184
  aria-label="Notifications"
52
185
  >
53
186
  <Bell className="size-5" aria-hidden />
54
187
  </Button>
55
- <div className="flex items-center gap-2">
56
- <div className="size-9 shrink-0 rounded-full bg-primary-foreground/25" aria-hidden />
57
- <span className="text-sm font-medium">SARAH</span>
58
- </div>
59
- </div>
60
- </header>
61
- <div className="relative flex min-h-0 flex-1">
62
- {!navHidden && (
63
- <nav
64
- className="absolute left-5 top-5 bottom-5 z-10 flex w-[200px] flex-col items-start gap-1 overflow-hidden rounded-2xl border border-border bg-card p-4 py-2 shadow-md"
65
- aria-label="Main navigation"
66
- >
188
+ {loading ? (
189
+ <span className="text-sm font-medium text-primary-foreground/70" aria-hidden>
190
+
191
+ </span>
192
+ ) : isAuthenticated && user ? (
193
+ <UserMenu />
194
+ ) : (
67
195
  <NavLink
68
- to="/"
69
- end
70
- className={({ isActive }) =>
71
- cn(
72
- "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
73
- isActive && "bg-primary/15 text-primary font-medium",
74
- )
75
- }
76
- aria-label="Home"
196
+ to="/login"
197
+ className="rounded-md px-2 py-1.5 text-sm font-medium text-primary-foreground no-underline transition-colors duration-200 hover:bg-primary-foreground/20 hover:text-primary-foreground/90"
77
198
  >
78
- <Home className="size-[22px] shrink-0" aria-hidden />
79
- <span className="text-sm font-medium">Home</span>
199
+ Sign Up / Sign In
80
200
  </NavLink>
81
- <NavLink
82
- to="/properties"
83
- className={({ isActive }) =>
84
- cn(
85
- "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
86
- isActive && "bg-primary/15 text-primary font-medium",
87
- )
88
- }
89
- aria-label="Property Search"
90
- >
91
- <Search className="size-[22px] shrink-0" aria-hidden />
92
- <span className="text-sm font-medium">Property Search</span>
93
- </NavLink>
94
- <NavLink
95
- to="/dashboard"
96
- className={({ isActive }) =>
97
- cn(
98
- "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
99
- isActive && "bg-primary/15 text-primary font-medium",
100
- )
101
- }
102
- aria-label="Dashboard"
103
- >
104
- <BarChart3 className="size-[22px] shrink-0" aria-hidden />
105
- <span className="text-sm font-medium">Dashboard</span>
106
- </NavLink>
107
- <NavLink
108
- to="/maintenance"
109
- className={({ isActive }) =>
110
- cn(
111
- "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
112
- isActive && "bg-primary/15 text-primary font-medium",
113
- )
114
- }
115
- aria-label="Maintenance"
116
- >
117
- <Wrench className="size-[22px] shrink-0" aria-hidden />
118
- <span className="text-sm font-medium">Maintenance</span>
119
- </NavLink>
120
- <NavLink
121
- to="/contact"
122
- className={({ isActive }) =>
123
- cn(
124
- "flex min-h-11 w-full flex-shrink-0 cursor-pointer items-center justify-start gap-3 rounded-xl px-3 py-2 text-muted-foreground no-underline transition-colors duration-200",
125
- isActive && "bg-primary/15 text-primary font-medium",
126
- )
127
- }
128
- aria-label="Contact"
129
- >
130
- <Phone className="size-[22px] shrink-0" aria-hidden />
131
- <span className="text-sm font-medium">Contact</span>
132
- </NavLink>
133
- </nav>
134
- )}
201
+ )}
202
+ </div>
203
+ </header>
204
+ <div className="relative flex min-h-0 flex-1">
205
+ {!navHidden && <Sidebar isAuthenticated={isAuthenticated} />}
135
206
  <main
136
207
  className="min-h-full flex-1 overflow-auto bg-muted/40 p-6 transition-[margin-left] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]"
137
208
  style={{
@@ -145,7 +216,3 @@ function AppShell() {
145
216
  </div>
146
217
  );
147
218
  }
148
-
149
- export default function AppLayout() {
150
- return <AppShell />;
151
- }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Extensible user profile fetching and updating via UI API GraphQL.
3
+ */
4
+ import { getDataSDK } from "@salesforce/sdk-data";
5
+ import { flattenGraphQLRecord } from "../utils/helpers";
6
+
7
+ const USER_PROFILE_FIELDS_FULL = `
8
+ Id
9
+ FirstName { value }
10
+ LastName { value }
11
+ Email { value }
12
+ Phone { value }
13
+ Street { value }
14
+ City { value }
15
+ State { value }
16
+ PostalCode { value }
17
+ Country { value }`;
18
+
19
+ function getUserProfileQuery(fields: string): string {
20
+ return `
21
+ query GetUserProfile($userId: ID) {
22
+ uiapi {
23
+ query {
24
+ User(where: { Id: { eq: $userId } }) {
25
+ edges {
26
+ node {${fields}}
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }`;
32
+ }
33
+
34
+ function getUserProfileMutation(fields: string): string {
35
+ return `
36
+ mutation UpdateUserProfile($input: UserUpdateInput!) {
37
+ uiapi {
38
+ UserUpdate(input: $input) {
39
+ Record {${fields}}
40
+ }
41
+ }
42
+ }`;
43
+ }
44
+
45
+ function throwOnGraphQLErrors(response: any): void {
46
+ if (response?.errors?.length) {
47
+ throw new Error(response.errors.map((e: any) => e.message).join("; "));
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Fetches the user profile via GraphQL and returns a flattened record.
53
+ * @param userId - The Salesforce User Id.
54
+ * @param fields - GraphQL field selection (defaults to USER_PROFILE_FIELDS_FULL).
55
+ */
56
+ export async function fetchUserProfile<T>(
57
+ userId: string,
58
+ fields: string = USER_PROFILE_FIELDS_FULL,
59
+ ): Promise<T> {
60
+ const data = await getDataSDK();
61
+ const response: any = await data.graphql?.(getUserProfileQuery(fields), { userId });
62
+ throwOnGraphQLErrors(response);
63
+ return flattenGraphQLRecord<T>(response?.data?.uiapi?.query?.User?.edges?.[0]?.node);
64
+ }
65
+
66
+ /**
67
+ * Updates the user profile via GraphQL and returns the flattened updated record.
68
+ * @param userId - The Salesforce User Id.
69
+ * @param values - The field values to update.
70
+ */
71
+ export async function updateUserProfile<T>(
72
+ userId: string,
73
+ values: Record<string, unknown>,
74
+ ): Promise<T> {
75
+ const data = await getDataSDK();
76
+ const response: any = await data.graphql?.(getUserProfileMutation(USER_PROFILE_FIELDS_FULL), {
77
+ input: { Id: userId, User: { ...values } },
78
+ });
79
+ throwOnGraphQLErrors(response);
80
+ return flattenGraphQLRecord<T>(response?.data?.uiapi?.UserUpdate?.Record);
81
+ }
@@ -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/webapp-experimental/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 getUser(): 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
+ }