@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.
Files changed (124) hide show
  1. package/LICENSE.txt +82 -0
  2. package/README.md +77 -0
  3. package/dist/.forceignore +15 -0
  4. package/dist/.husky/pre-commit +4 -0
  5. package/dist/.prettierignore +11 -0
  6. package/dist/.prettierrc +17 -0
  7. package/dist/AGENT.md +193 -0
  8. package/dist/CHANGELOG.md +2128 -0
  9. package/dist/README.md +28 -0
  10. package/dist/config/project-scratch-def.json +13 -0
  11. package/dist/eslint.config.js +7 -0
  12. package/dist/force-app/main/default/classes/UIBundleAuthUtils.cls +68 -0
  13. package/dist/force-app/main/default/classes/UIBundleAuthUtils.cls-meta.xml +5 -0
  14. package/dist/force-app/main/default/classes/UIBundleChangePassword.cls +77 -0
  15. package/dist/force-app/main/default/classes/UIBundleChangePassword.cls-meta.xml +5 -0
  16. package/dist/force-app/main/default/classes/UIBundleForgotPassword.cls +71 -0
  17. package/dist/force-app/main/default/classes/UIBundleForgotPassword.cls-meta.xml +5 -0
  18. package/dist/force-app/main/default/classes/UIBundleLogin.cls +105 -0
  19. package/dist/force-app/main/default/classes/UIBundleLogin.cls-meta.xml +5 -0
  20. package/dist/force-app/main/default/classes/UIBundleRegistration.cls +162 -0
  21. package/dist/force-app/main/default/classes/UIBundleRegistration.cls-meta.xml +5 -0
  22. package/dist/force-app/main/default/uiBundles/feature-react-authentication/.forceignore +15 -0
  23. package/dist/force-app/main/default/uiBundles/feature-react-authentication/.graphqlrc.yml +2 -0
  24. package/dist/force-app/main/default/uiBundles/feature-react-authentication/.prettierignore +9 -0
  25. package/dist/force-app/main/default/uiBundles/feature-react-authentication/.prettierrc +11 -0
  26. package/dist/force-app/main/default/uiBundles/feature-react-authentication/CHANGELOG.md +10 -0
  27. package/dist/force-app/main/default/uiBundles/feature-react-authentication/README.md +75 -0
  28. package/dist/force-app/main/default/uiBundles/feature-react-authentication/codegen.yml +95 -0
  29. package/dist/force-app/main/default/uiBundles/feature-react-authentication/components.json +18 -0
  30. package/dist/force-app/main/default/uiBundles/feature-react-authentication/e2e/app.spec.ts +17 -0
  31. package/dist/force-app/main/default/uiBundles/feature-react-authentication/eslint.config.js +169 -0
  32. package/dist/force-app/main/default/uiBundles/feature-react-authentication/feature-react-authentication.uibundle-meta.xml +7 -0
  33. package/dist/force-app/main/default/uiBundles/feature-react-authentication/index.html +12 -0
  34. package/dist/force-app/main/default/uiBundles/feature-react-authentication/package.json +70 -0
  35. package/dist/force-app/main/default/uiBundles/feature-react-authentication/playwright.config.ts +24 -0
  36. package/dist/force-app/main/default/uiBundles/feature-react-authentication/scripts/get-graphql-schema.mjs +68 -0
  37. package/dist/force-app/main/default/uiBundles/feature-react-authentication/scripts/rewrite-e2e-assets.mjs +23 -0
  38. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/api/graphqlClient.ts +25 -0
  39. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/app.tsx +16 -0
  40. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/appLayout.tsx +83 -0
  41. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/book.svg +3 -0
  42. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/copy.svg +4 -0
  43. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/rocket.svg +3 -0
  44. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/icons/star.svg +3 -0
  45. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-1.png +0 -0
  46. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-2.png +0 -0
  47. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/codey-3.png +0 -0
  48. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/assets/images/vibe-codey.svg +194 -0
  49. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/alerts/status-alert.tsx +49 -0
  50. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/layouts/card-layout.tsx +29 -0
  51. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/alert.tsx +76 -0
  52. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/badge.tsx +48 -0
  53. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/breadcrumb.tsx +109 -0
  54. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/button.tsx +67 -0
  55. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/calendar.tsx +232 -0
  56. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/card.tsx +103 -0
  57. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/checkbox.tsx +32 -0
  58. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/collapsible.tsx +33 -0
  59. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/datePicker.tsx +127 -0
  60. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/dialog.tsx +162 -0
  61. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/field.tsx +237 -0
  62. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/index.ts +84 -0
  63. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/input.tsx +19 -0
  64. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/label.tsx +22 -0
  65. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/pagination.tsx +132 -0
  66. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/popover.tsx +89 -0
  67. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/select.tsx +193 -0
  68. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/separator.tsx +26 -0
  69. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/skeleton.tsx +14 -0
  70. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/sonner.tsx +20 -0
  71. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/spinner.tsx +16 -0
  72. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/table.tsx +114 -0
  73. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/components/ui/tabs.tsx +88 -0
  74. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/api/userProfileApi.ts +95 -0
  75. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/authHelpers.ts +73 -0
  76. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/authenticationConfig.ts +61 -0
  77. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/context/AuthContext.tsx +95 -0
  78. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/footers/footer-link.tsx +36 -0
  79. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/forms/auth-form.tsx +81 -0
  80. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/forms/submit-button.tsx +49 -0
  81. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/form.tsx +120 -0
  82. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/useCountdownTimer.ts +266 -0
  83. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/hooks/useRetryWithBackoff.ts +109 -0
  84. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layout/card-skeleton.tsx +38 -0
  85. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layout/centered-page-layout.tsx +87 -0
  86. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/AuthAppLayout.tsx +12 -0
  87. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/authenticationRouteLayout.tsx +21 -0
  88. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/layouts/privateRouteLayout.tsx +44 -0
  89. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ChangePassword.tsx +107 -0
  90. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ForgotPassword.tsx +73 -0
  91. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Login.tsx +97 -0
  92. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Profile.tsx +161 -0
  93. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/Register.tsx +133 -0
  94. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/pages/ResetPassword.tsx +107 -0
  95. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +602 -0
  96. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/sessionTimeService.ts +149 -0
  97. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/sessionTimeout/sessionTimeoutConfig.ts +77 -0
  98. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/features/authentication/utils/helpers.ts +121 -0
  99. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/lib/utils.ts +6 -0
  100. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/navigationMenu.tsx +80 -0
  101. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/pages/Home.tsx +12 -0
  102. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/pages/NotFound.tsx +18 -0
  103. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/router-utils.tsx +35 -0
  104. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/routes.tsx +71 -0
  105. package/dist/force-app/main/default/uiBundles/feature-react-authentication/src/styles/global.css +135 -0
  106. package/dist/force-app/main/default/uiBundles/feature-react-authentication/tsconfig.json +42 -0
  107. package/dist/force-app/main/default/uiBundles/feature-react-authentication/tsconfig.node.json +13 -0
  108. package/dist/force-app/main/default/uiBundles/feature-react-authentication/ui-bundle.json +7 -0
  109. package/dist/force-app/main/default/uiBundles/feature-react-authentication/vite-env.d.ts +1 -0
  110. package/dist/force-app/main/default/uiBundles/feature-react-authentication/vite.config.ts +106 -0
  111. package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest-env.d.ts +2 -0
  112. package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest.config.ts +11 -0
  113. package/dist/force-app/main/default/uiBundles/feature-react-authentication/vitest.setup.ts +1 -0
  114. package/dist/jest.config.js +6 -0
  115. package/dist/package-lock.json +9995 -0
  116. package/dist/package.json +40 -0
  117. package/dist/scripts/apex/hello.apex +10 -0
  118. package/dist/scripts/graphql-search.sh +191 -0
  119. package/dist/scripts/prepare-import-unique-fields.js +122 -0
  120. package/dist/scripts/setup-cli.mjs +563 -0
  121. package/dist/scripts/sf-project-setup.mjs +66 -0
  122. package/dist/scripts/soql/account.soql +6 -0
  123. package/dist/sfdx-project.json +12 -0
  124. package/package.json +44 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Custom hook for countdown timer with accessibility features
3
+ */
4
+
5
+ import { useState, useEffect, useRef, useCallback } from "react";
6
+
7
+ /**
8
+ * Accessibility configuration for countdown timer
9
+ */
10
+ export interface CountdownTimerA11yConfig {
11
+ /** Announce time remaining at these specific second marks */
12
+ ANNOUNCE_AT_SECONDS: readonly number[];
13
+ /** Announce every N seconds (after initial period) */
14
+ ANNOUNCE_INTERVAL_SECONDS: number;
15
+ /** Minimum elapsed time before starting interval announcements */
16
+ MIN_ELAPSED_FOR_INTERVAL: number;
17
+ }
18
+
19
+ /**
20
+ * Return value from useCountdownTimer hook
21
+ */
22
+ export interface CountdownTimerResult {
23
+ /** Current time remaining in seconds */
24
+ displayTime: number;
25
+ /** Formatted time string (MM:SS) */
26
+ formattedTime: string;
27
+ /** ISO 8601 duration string (e.g., PT2M15S) */
28
+ isoTime: string;
29
+ /** Accessibility announcement text for screen readers */
30
+ accessibilityAnnouncement: string;
31
+ /** Start the countdown timer */
32
+ start: () => void;
33
+ /** Stop the countdown timer */
34
+ stop: () => void;
35
+ /** Reset the countdown timer to initial time */
36
+ reset: () => void;
37
+ }
38
+
39
+ /**
40
+ * Configuration for countdown timer hook
41
+ */
42
+ export interface CountdownTimerConfig {
43
+ /** Initial time in seconds */
44
+ initialTime: number;
45
+ /** Callback when countdown reaches 0 */
46
+ onExpire: () => void;
47
+ /** Optional accessibility configuration */
48
+ a11yConfig?: CountdownTimerA11yConfig;
49
+ }
50
+
51
+ /**
52
+ * Default accessibility configuration
53
+ */
54
+ const DEFAULT_A11Y_CONFIG: CountdownTimerA11yConfig = {
55
+ ANNOUNCE_AT_SECONDS: [5, 1],
56
+ ANNOUNCE_INTERVAL_SECONDS: 10,
57
+ MIN_ELAPSED_FOR_INTERVAL: 10,
58
+ };
59
+
60
+ /**
61
+ * Format time remaining as MM:SS string
62
+ * Uses Intl.NumberFormat for zero-padding and internationalization
63
+ *
64
+ * @param seconds - Total seconds remaining
65
+ * @returns Formatted time string (e.g., "05:23")
66
+ */
67
+ function formatTimeRemaining(seconds: number): string {
68
+ const minutes = Math.floor(seconds / 60);
69
+ const secs = seconds % 60;
70
+
71
+ // Use Intl.NumberFormat for zero-padding with internationalization
72
+ const formatter = new Intl.NumberFormat(navigator.language, {
73
+ minimumIntegerDigits: 2,
74
+ useGrouping: false,
75
+ });
76
+
77
+ return `${formatter.format(minutes)}:${formatter.format(secs)}`;
78
+ }
79
+
80
+ /**
81
+ * Format time remaining as ISO 8601 duration for ARIA
82
+ * Used in datetime attribute of <time> element
83
+ *
84
+ * @param seconds - Total seconds remaining
85
+ * @returns ISO 8601 duration string (e.g., "PT2M15S")
86
+ */
87
+ function formatISODuration(seconds: number): string {
88
+ const minutes = Math.floor(seconds / 60);
89
+ const secs = seconds % 60;
90
+ return `PT${minutes}M${secs}S`;
91
+ }
92
+
93
+ /**
94
+ * Format time remaining for screen reader announcement
95
+ * Uses Intl.DurationFormat if available, falls back to manual formatting
96
+ *
97
+ * @param seconds - Total seconds remaining
98
+ * @returns Formatted announcement text (e.g., "2 minutes 15 seconds")
99
+ */
100
+ function formatAccessibilityAnnouncement(seconds: number): string {
101
+ const minutes = Math.floor(seconds / 60);
102
+ const secs = seconds % 60;
103
+
104
+ // Try using Intl.DurationFormat (newer API, may not be available in all browsers)
105
+ if (typeof Intl !== "undefined" && "DurationFormat" in Intl) {
106
+ try {
107
+ // @ts-expect-error - DurationFormat is not yet in TypeScript lib
108
+ const formatter = new Intl.DurationFormat(navigator.language, { style: "long" });
109
+ return formatter.format({ minutes, seconds: secs });
110
+ } catch {
111
+ // Fallback to manual formatting
112
+ }
113
+ }
114
+
115
+ // Manual fallback
116
+ const parts: string[] = [];
117
+ if (minutes > 0) {
118
+ parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
119
+ }
120
+ if (secs > 0 || minutes === 0) {
121
+ parts.push(`${secs} ${secs === 1 ? "second" : "seconds"}`);
122
+ }
123
+ return parts.join(" ");
124
+ }
125
+
126
+ /**
127
+ * Determine if an accessibility announcement should be made at this time
128
+ *
129
+ * @param currentTime - Current countdown time in seconds
130
+ * @param initialTime - Initial countdown time in seconds
131
+ * @param config - Accessibility configuration
132
+ * @returns True if announcement should be made
133
+ */
134
+ function shouldAnnounce(
135
+ currentTime: number,
136
+ initialTime: number,
137
+ config: CountdownTimerA11yConfig,
138
+ ): boolean {
139
+ // Announce at specific second marks (5s, 1s)
140
+ if (config.ANNOUNCE_AT_SECONDS.includes(currentTime)) {
141
+ return true;
142
+ }
143
+
144
+ // Calculate elapsed time
145
+ const elapsed = initialTime - currentTime;
146
+
147
+ // Announce every N seconds after minimum elapsed time
148
+ if (elapsed >= config.MIN_ELAPSED_FOR_INTERVAL) {
149
+ return currentTime % config.ANNOUNCE_INTERVAL_SECONDS === 0;
150
+ }
151
+
152
+ return false;
153
+ }
154
+
155
+ /**
156
+ * Custom hook for countdown timer with accessibility
157
+ * Decrements from initial time to 0, provides formatted output for display and ARIA
158
+ *
159
+ * @param config - Timer configuration
160
+ * @returns Timer state and control functions
161
+ *
162
+ * @example
163
+ * const timer = useCountdownTimer({
164
+ * initialTime: 300, // 5 minutes
165
+ * onExpire: () => handleLogout()
166
+ * });
167
+ *
168
+ * timer.start(); // Begin countdown
169
+ *
170
+ * <time dateTime={timer.isoTime} role="timer">
171
+ * {timer.formattedTime}
172
+ * </time>
173
+ * <div role="status" aria-live="polite" className="sr-only">
174
+ * {timer.accessibilityAnnouncement}
175
+ * </div>
176
+ */
177
+ export function useCountdownTimer({
178
+ initialTime,
179
+ onExpire,
180
+ a11yConfig = DEFAULT_A11Y_CONFIG,
181
+ }: CountdownTimerConfig): CountdownTimerResult {
182
+ const [displayTime, setDisplayTime] = useState(initialTime);
183
+ const [accessibilityAnnouncement, setAccessibilityAnnouncement] = useState("");
184
+ const [isActive, setIsActive] = useState(false);
185
+
186
+ // Use refs to avoid stale closure issues
187
+ const initialTimeRef = useRef(initialTime);
188
+ const onExpireRef = useRef(onExpire);
189
+ const a11yConfigRef = useRef(a11yConfig);
190
+ const endTimeRef = useRef<number>(0);
191
+ const previousTimeRef = useRef<number>(initialTime);
192
+
193
+ // Update refs when props change
194
+ useEffect(() => {
195
+ initialTimeRef.current = initialTime;
196
+ }, [initialTime]);
197
+
198
+ useEffect(() => {
199
+ onExpireRef.current = onExpire;
200
+ }, [onExpire]);
201
+
202
+ useEffect(() => {
203
+ a11yConfigRef.current = a11yConfig;
204
+ }, [a11yConfig]);
205
+
206
+ // Countdown effect using Date.now() for accuracy
207
+ useEffect(() => {
208
+ if (!isActive) return;
209
+
210
+ // Set the target end time when timer starts
211
+ endTimeRef.current = Date.now() + initialTimeRef.current * 1000;
212
+ previousTimeRef.current = initialTimeRef.current;
213
+
214
+ const intervalId = setInterval(() => {
215
+ const now = Date.now();
216
+ const remainingMs = endTimeRef.current - now;
217
+ const newTime = Math.max(0, Math.ceil(remainingMs / 1000));
218
+
219
+ setDisplayTime(newTime);
220
+
221
+ // Check if we should make an accessibility announcement
222
+ // Only announce when the second value changes
223
+ if (
224
+ newTime !== previousTimeRef.current &&
225
+ shouldAnnounce(newTime, initialTimeRef.current, a11yConfigRef.current)
226
+ ) {
227
+ setAccessibilityAnnouncement(formatAccessibilityAnnouncement(newTime));
228
+ }
229
+ previousTimeRef.current = newTime;
230
+
231
+ // Check if countdown expired
232
+ if (newTime <= 0) {
233
+ clearInterval(intervalId);
234
+ setIsActive(false);
235
+ onExpireRef.current();
236
+ }
237
+ }, 100); // Check more frequently for smoother updates
238
+
239
+ return () => clearInterval(intervalId);
240
+ }, [isActive]);
241
+
242
+ // Control functions
243
+ const start = useCallback(() => {
244
+ setIsActive(true);
245
+ }, []);
246
+
247
+ const stop = useCallback(() => {
248
+ setIsActive(false);
249
+ }, []);
250
+
251
+ const reset = useCallback(() => {
252
+ setDisplayTime(initialTimeRef.current);
253
+ setIsActive(false);
254
+ setAccessibilityAnnouncement("");
255
+ }, []);
256
+
257
+ return {
258
+ displayTime,
259
+ formattedTime: formatTimeRemaining(displayTime),
260
+ isoTime: formatISODuration(displayTime),
261
+ accessibilityAnnouncement,
262
+ start,
263
+ stop,
264
+ reset,
265
+ };
266
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Custom hook for retry logic with exponential backoff
3
+ */
4
+
5
+ import { useState, useCallback } from "react";
6
+
7
+ export interface UseRetryWithBackoffOptions {
8
+ /** Initial delay in milliseconds */
9
+ initialDelay: number;
10
+ /** Maximum number of retry attempts */
11
+ maxAttempts: number;
12
+ /** Maximum delay in milliseconds */
13
+ maxDelay: number;
14
+ }
15
+
16
+ export interface UseRetryWithBackoffResult {
17
+ /** Current number of retry attempts */
18
+ retryAttempts: number;
19
+ /** Current retry delay in milliseconds */
20
+ currentRetryDelay: number;
21
+ /** Whether max retry attempts have been reached */
22
+ maxRetriesReached: boolean;
23
+ /** Schedule a retry with exponential backoff, returns timeout ID for cancellation */
24
+ scheduleRetry: (callback: () => void) => ReturnType<typeof setTimeout> | undefined;
25
+ /** Reset retry state after successful operation */
26
+ resetRetry: () => void;
27
+ }
28
+
29
+ /**
30
+ * Hook for managing retry logic with exponential backoff
31
+ *
32
+ * @param options - Configuration for retry behavior
33
+ * @returns Retry state and control functions
34
+ *
35
+ * @example
36
+ * const retry = useRetryWithBackoff({
37
+ * initialDelay: 2000,
38
+ * maxAttempts: 10,
39
+ * maxDelay: 1800000
40
+ * });
41
+ *
42
+ * async function fetchData() {
43
+ * try {
44
+ * const data = await apiCall();
45
+ * retry.resetRetry();
46
+ * return data;
47
+ * } catch (error) {
48
+ * if (retry.maxRetriesReached) {
49
+ * console.error('Max retries reached');
50
+ * return;
51
+ * }
52
+ * retry.scheduleRetry(() => fetchData());
53
+ * }
54
+ * }
55
+ */
56
+ export function useRetryWithBackoff(
57
+ options: UseRetryWithBackoffOptions,
58
+ ): UseRetryWithBackoffResult {
59
+ const { initialDelay, maxAttempts, maxDelay } = options;
60
+
61
+ const [retryAttempts, setRetryAttempts] = useState<number>(0);
62
+ const [currentRetryDelay, setCurrentRetryDelay] = useState<number>(initialDelay);
63
+
64
+ const maxRetriesReached = retryAttempts >= maxAttempts;
65
+
66
+ /**
67
+ * Reset retry state after successful operation
68
+ */
69
+ const resetRetry = useCallback(() => {
70
+ setRetryAttempts(0);
71
+ setCurrentRetryDelay(initialDelay);
72
+ }, [initialDelay]);
73
+
74
+ /**
75
+ * Schedule a retry with exponential backoff
76
+ * Returns the timeout ID which can be used to cancel the retry if needed
77
+ */
78
+ const scheduleRetry = useCallback(
79
+ (callback: () => void) => {
80
+ if (retryAttempts >= maxAttempts) {
81
+ console.error("[useRetryWithBackoff] Max retry attempts reached");
82
+ return undefined;
83
+ }
84
+
85
+ console.warn(
86
+ `[useRetryWithBackoff] Retry attempt ${retryAttempts + 1}/${maxAttempts} in ${currentRetryDelay}ms`,
87
+ );
88
+
89
+ const timeoutId = setTimeout(() => {
90
+ callback();
91
+ }, currentRetryDelay);
92
+
93
+ setRetryAttempts((prev) => prev + 1);
94
+ // Double the delay for next retry, capped at maxDelay
95
+ setCurrentRetryDelay((prev) => Math.min(prev * 2, maxDelay));
96
+
97
+ return timeoutId;
98
+ },
99
+ [retryAttempts, currentRetryDelay, maxAttempts, maxDelay],
100
+ );
101
+
102
+ return {
103
+ retryAttempts,
104
+ currentRetryDelay,
105
+ maxRetriesReached,
106
+ scheduleRetry,
107
+ resetRetry,
108
+ };
109
+ }
@@ -0,0 +1,38 @@
1
+ import { CenteredPageLayout } from "./centered-page-layout";
2
+ import { Card, CardContent, CardHeader } from "../../../components/ui/card";
3
+ import { Skeleton } from "../../../components/ui/skeleton";
4
+
5
+ interface CardSkeletonProps {
6
+ /**
7
+ * Maximum width of the content container.
8
+ * @default "sm"
9
+ */
10
+ contentMaxWidth?: "sm" | "md" | "lg";
11
+ /**
12
+ * Accessible label for screen readers.
13
+ * @default "Loading…"
14
+ */
15
+ loadingText?: string;
16
+ }
17
+
18
+ /**
19
+ * Full-page loading indicator with skeleton card placeholder.
20
+ */
21
+ export function CardSkeleton({ contentMaxWidth, loadingText = "Loading…" }: CardSkeletonProps) {
22
+ return (
23
+ <CenteredPageLayout contentMaxWidth={contentMaxWidth}>
24
+ <div role="status" aria-live="polite">
25
+ <Card className="w-full">
26
+ <CardHeader>
27
+ <Skeleton className="h-4 w-2/3" />
28
+ <Skeleton className="h-4 w-1/2" />
29
+ </CardHeader>
30
+ <CardContent>
31
+ <Skeleton className="aspect-video w-full" />
32
+ </CardContent>
33
+ </Card>
34
+ <span className="sr-only">{loadingText}</span>
35
+ </div>
36
+ </CenteredPageLayout>
37
+ );
38
+ }
@@ -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,44 @@
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
+ export interface PrivateRouteProps {
7
+ /**
8
+ * Whether to show a card skeleton placeholder while authentication is loading.
9
+ * @default false
10
+ */
11
+ showCardSkeleton?: boolean;
12
+ }
13
+
14
+ /**
15
+ * [Dev Note] Route Guard:
16
+ * Renders the child route (Outlet) if the user is authenticated.
17
+ * Otherwise, redirects to Login with a 'startUrl' parameter so the user can be
18
+ * returned to this page after successful login.
19
+ */
20
+ export default function PrivateRoute({ showCardSkeleton = false }: PrivateRouteProps) {
21
+ const { isAuthenticated, loading } = useAuth();
22
+ const location = useLocation();
23
+
24
+ if (loading) return showCardSkeleton ? <CardSkeleton contentMaxWidth="md" /> : null;
25
+
26
+ if (!isAuthenticated) {
27
+ const searchParams = new URLSearchParams();
28
+
29
+ // [Dev Note] Capture current location to return after login
30
+ const destination = location.pathname + location.search;
31
+ searchParams.set(AUTH_REDIRECT_PARAM, destination);
32
+ return (
33
+ <Navigate // Navigate accepts an object to safely construct the URL
34
+ to={{
35
+ pathname: ROUTES.LOGIN.PATH,
36
+ search: searchParams.toString(),
37
+ }}
38
+ replace
39
+ />
40
+ );
41
+ }
42
+
43
+ return <Outlet />;
44
+ }
@@ -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 { createDataSDK } 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 createDataSDK();
32
+ const response = await sdk.fetch!("/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
+ }