@rebasepro/app 0.17.3 → 0.18.1

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 (185) hide show
  1. package/README.md +4 -1
  2. package/dist/components/index.d.ts +0 -3
  3. package/dist/contexts/AuthControllerContext.d.ts +2 -1
  4. package/dist/contexts/CustomizationControllerContext.d.ts +2 -1
  5. package/dist/contexts/DialogsProvider.d.ts +2 -1
  6. package/dist/contexts/RebaseDataContext.d.ts +10 -1
  7. package/dist/contexts/StorageSourceContext.d.ts +11 -1
  8. package/dist/core/RebaseProps.d.ts +2 -12
  9. package/dist/debug/index.d.ts +19 -0
  10. package/dist/debug.js +3631 -0
  11. package/dist/hooks/data/delete.d.ts +16 -7
  12. package/dist/hooks/data/save.d.ts +18 -4
  13. package/dist/hooks/index.d.ts +1 -0
  14. package/dist/hooks/useAdminModeController.d.ts +5 -4
  15. package/dist/hooks/useNavigationGroupLabel.d.ts +17 -0
  16. package/dist/hooks/useStudioBridge.d.ts +33 -0
  17. package/dist/index.es.js +2599 -6980
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/vitePlugin.d.ts +22 -0
  20. package/dist/vitePlugin.js +40 -1
  21. package/package.json +42 -31
  22. package/src/auth/api.ts +0 -134
  23. package/src/auth/index.ts +0 -26
  24. package/src/auth/types.ts +0 -115
  25. package/src/auth/useRebaseAuthController.ts +0 -345
  26. package/src/collections/collection_view_config.ts +0 -76
  27. package/src/collections/entity-display-cache.ts +0 -195
  28. package/src/collections/entity-display.ts +0 -77
  29. package/src/collections/entity_image_preview.ts +0 -61
  30. package/src/collections/filter-operator-resolution.ts +0 -119
  31. package/src/collections/form-layout.ts +0 -394
  32. package/src/collections/index.ts +0 -13
  33. package/src/collections/navigation_from_path.ts +0 -123
  34. package/src/collections/navigation_utils.ts +0 -171
  35. package/src/collections/parent_references_from_path.ts +0 -59
  36. package/src/collections/property-path.ts +0 -30
  37. package/src/collections/property_presentation.ts +0 -264
  38. package/src/collections/summary-property.ts +0 -114
  39. package/src/collections/title-property.ts +0 -312
  40. package/src/components/AIIcon.tsx +0 -47
  41. package/src/components/AuthSimulationMenu.tsx +0 -0
  42. package/src/components/ConfirmationDialog.tsx +0 -48
  43. package/src/components/Debug/UIReferenceView.tsx +0 -1053
  44. package/src/components/Debug/UIStyleGuide.tsx +0 -159
  45. package/src/components/Debug/collection-views/CardViewDemo.tsx +0 -87
  46. package/src/components/Debug/collection-views/CollectionTableDemo.tsx +0 -107
  47. package/src/components/Debug/collection-views/KanbanBoardDemo.tsx +0 -89
  48. package/src/components/Debug/collection-views/index.ts +0 -3
  49. package/src/components/Debug/collection-views/sample_data.ts +0 -44
  50. package/src/components/Debug/crm-dashboard/CalendarWidget.tsx +0 -387
  51. package/src/components/Debug/crm-dashboard/CrmDashboardDemo.tsx +0 -1100
  52. package/src/components/Debug/crm-dashboard/DashboardMetrics.tsx +0 -131
  53. package/src/components/Debug/crm-dashboard/PipelineOverview.tsx +0 -288
  54. package/src/components/Debug/crm-dashboard/RecentActivity.tsx +0 -213
  55. package/src/components/Debug/crm-dashboard/TaskFilters.tsx +0 -182
  56. package/src/components/Debug/crm-dashboard/TaskTable.tsx +0 -425
  57. package/src/components/Debug/crm-dashboard/TasksView.tsx +0 -234
  58. package/src/components/ErrorTooltip.tsx +0 -12
  59. package/src/components/ErrorView.tsx +0 -94
  60. package/src/components/LanguageToggle.tsx +0 -59
  61. package/src/components/LoginView/LoginView.tsx +0 -1235
  62. package/src/components/LoginView/index.ts +0 -2
  63. package/src/components/LoginView/oauth-redirect-flow.ts +0 -184
  64. package/src/components/NotFoundPage.tsx +0 -27
  65. package/src/components/RebaseAuth.tsx +0 -27
  66. package/src/components/RebaseLogo.tsx +0 -88
  67. package/src/components/SchemaDriftBanner.tsx +0 -102
  68. package/src/components/UnsavedChangesDialog.tsx +0 -46
  69. package/src/components/UserDisplay.tsx +0 -54
  70. package/src/components/UserSelectPopover.tsx +0 -399
  71. package/src/components/UserSettingsView.tsx +0 -318
  72. package/src/components/common/index.ts +0 -6
  73. package/src/components/common/table_height.tsx +0 -21
  74. package/src/components/common/types.tsx +0 -66
  75. package/src/components/common/useColumnsIds.tsx +0 -261
  76. package/src/components/common/useDataTableController.tsx +0 -571
  77. package/src/components/common/useDebouncedData.ts +0 -49
  78. package/src/components/common/useScrollRestoration.tsx +0 -68
  79. package/src/components/index.tsx +0 -26
  80. package/src/contexts/AdminModeController.tsx +0 -11
  81. package/src/contexts/AnalyticsContext.tsx +0 -4
  82. package/src/contexts/AuthControllerContext.tsx +0 -4
  83. package/src/contexts/CollectionResolverContext.tsx +0 -24
  84. package/src/contexts/CollectionScopeContext.tsx +0 -68
  85. package/src/contexts/ComponentOverrideContext.tsx +0 -81
  86. package/src/contexts/CustomizationControllerContext.tsx +0 -4
  87. package/src/contexts/DataDriverContext.tsx +0 -4
  88. package/src/contexts/DataSourcesContext.tsx +0 -36
  89. package/src/contexts/DatabaseAdminContext.tsx +0 -4
  90. package/src/contexts/DialogsProvider.tsx +0 -56
  91. package/src/contexts/EffectiveRoleController.tsx +0 -12
  92. package/src/contexts/ModeController.tsx +0 -11
  93. package/src/contexts/RebaseClientInstanceContext.tsx +0 -7
  94. package/src/contexts/RebaseDataContext.tsx +0 -4
  95. package/src/contexts/SnackbarProvider.tsx +0 -15
  96. package/src/contexts/StorageSourceContext.tsx +0 -4
  97. package/src/contexts/StorageSourcesContext.tsx +0 -30
  98. package/src/contexts/UserConfigurationPersistenceContext.tsx +0 -4
  99. package/src/contexts/index.ts +0 -18
  100. package/src/core/PluginLifecycleManager.tsx +0 -96
  101. package/src/core/PluginProviderStack.tsx +0 -40
  102. package/src/core/Rebase.tsx +0 -488
  103. package/src/core/RebaseProps.tsx +0 -333
  104. package/src/core/RebaseRouter.tsx +0 -18
  105. package/src/core/RebaseRoutes.tsx +0 -24
  106. package/src/core/index.tsx +0 -5
  107. package/src/hooks/ApiConfigContext.tsx +0 -83
  108. package/src/hooks/data/collectionQuery.ts +0 -55
  109. package/src/hooks/data/delete.ts +0 -56
  110. package/src/hooks/data/save.ts +0 -78
  111. package/src/hooks/data/useCollection.tsx +0 -183
  112. package/src/hooks/data/useData.tsx +0 -18
  113. package/src/hooks/data/useDataOrder.ts +0 -26
  114. package/src/hooks/data/useFetch.tsx +0 -156
  115. package/src/hooks/data/useRelationSelector.tsx +0 -280
  116. package/src/hooks/index.tsx +0 -45
  117. package/src/hooks/useAdminModeController.tsx +0 -23
  118. package/src/hooks/useAnalyticsController.tsx +0 -8
  119. package/src/hooks/useAuthController.tsx +0 -15
  120. package/src/hooks/useAuthSubscription.ts +0 -130
  121. package/src/hooks/useBackendStorageSource.ts +0 -295
  122. package/src/hooks/useBridgeRegistration.tsx +0 -32
  123. package/src/hooks/useBrowserTitleAndIcon.tsx +0 -23
  124. package/src/hooks/useBuildAdminModeController.tsx +0 -29
  125. package/src/hooks/useBuildEffectiveRoleController.tsx +0 -30
  126. package/src/hooks/useBuildLocalConfigurationPersistence.tsx +0 -83
  127. package/src/hooks/useBuildModeController.tsx +0 -73
  128. package/src/hooks/useClipboard.tsx +0 -158
  129. package/src/hooks/useCollapsedGroups.ts +0 -116
  130. package/src/hooks/useComponentOverride.tsx +0 -59
  131. package/src/hooks/useCustomizationController.tsx +0 -14
  132. package/src/hooks/useDialogsController.tsx +0 -15
  133. package/src/hooks/useEffectiveRoleController.tsx +0 -10
  134. package/src/hooks/useLargeLayout.tsx +0 -65
  135. package/src/hooks/useModeController.tsx +0 -23
  136. package/src/hooks/useNavigationBlocker.tsx +0 -108
  137. package/src/hooks/usePermissions.ts +0 -45
  138. package/src/hooks/useRebaseClient.tsx +0 -10
  139. package/src/hooks/useRebaseContext.tsx +0 -84
  140. package/src/hooks/useRebaseRegistry.tsx +0 -91
  141. package/src/hooks/useResolvedComponent.tsx +0 -195
  142. package/src/hooks/useSlot.tsx +0 -64
  143. package/src/hooks/useSnackbarController.tsx +0 -80
  144. package/src/hooks/useStorageSource.tsx +0 -11
  145. package/src/hooks/useStudioBridge.tsx +0 -258
  146. package/src/hooks/useTranslation.ts +0 -38
  147. package/src/hooks/useUnsavedChangesDialog.tsx +0 -67
  148. package/src/hooks/useUserConfigurationPersistence.tsx +0 -11
  149. package/src/i18n/RebaseI18nProvider.tsx +0 -159
  150. package/src/index.ts +0 -32
  151. package/src/internal/common.tsx +0 -28
  152. package/src/internal/useRestoreScroll.tsx +0 -69
  153. package/src/locales/de.ts +0 -941
  154. package/src/locales/en.ts +0 -1021
  155. package/src/locales/es.ts +0 -962
  156. package/src/locales/fr.ts +0 -941
  157. package/src/locales/hi.ts +0 -941
  158. package/src/locales/it.ts +0 -941
  159. package/src/locales/pt.ts +0 -949
  160. package/src/util/constants.ts +0 -7
  161. package/src/util/createFormexStub.tsx +0 -66
  162. package/src/util/entity_cache.ts +0 -242
  163. package/src/util/enums.ts +0 -34
  164. package/src/util/icon_list.ts +0 -22
  165. package/src/util/icons.tsx +0 -129
  166. package/src/util/index.ts +0 -13
  167. package/src/util/local_storage.ts +0 -66
  168. package/src/util/previews.ts +0 -127
  169. package/src/util/useStorageUploadController.tsx +0 -354
  170. package/src/vitePlugin.ts +0 -215
  171. /package/dist/{components/Debug → debug}/UIReferenceView.d.ts +0 -0
  172. /package/dist/{components/Debug → debug}/UIStyleGuide.d.ts +0 -0
  173. /package/dist/{components/Debug → debug}/collection-views/CardViewDemo.d.ts +0 -0
  174. /package/dist/{components/Debug → debug}/collection-views/CollectionTableDemo.d.ts +0 -0
  175. /package/dist/{components/Debug → debug}/collection-views/KanbanBoardDemo.d.ts +0 -0
  176. /package/dist/{components/Debug → debug}/collection-views/index.d.ts +0 -0
  177. /package/dist/{components/Debug → debug}/collection-views/sample_data.d.ts +0 -0
  178. /package/dist/{components/Debug → debug}/crm-dashboard/CalendarWidget.d.ts +0 -0
  179. /package/dist/{components/Debug → debug}/crm-dashboard/CrmDashboardDemo.d.ts +0 -0
  180. /package/dist/{components/Debug → debug}/crm-dashboard/DashboardMetrics.d.ts +0 -0
  181. /package/dist/{components/Debug → debug}/crm-dashboard/PipelineOverview.d.ts +0 -0
  182. /package/dist/{components/Debug → debug}/crm-dashboard/RecentActivity.d.ts +0 -0
  183. /package/dist/{components/Debug → debug}/crm-dashboard/TaskFilters.d.ts +0 -0
  184. /package/dist/{components/Debug → debug}/crm-dashboard/TaskTable.d.ts +0 -0
  185. /package/dist/{components/Debug → debug}/crm-dashboard/TasksView.d.ts +0 -0
@@ -1,1235 +0,0 @@
1
- import React, { ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
2
-
3
- /** Google Identity Services SDK — injected by the GIS <script> tag. */
4
- declare global {
5
- interface Window {
6
- google?: {
7
- accounts: {
8
- oauth2: {
9
- initCodeClient(config: {
10
- client_id: string;
11
- scope: string;
12
- ux_mode: "popup" | "redirect";
13
- callback: (response: { code?: string; error?: string }) => void;
14
- }): { requestCode(): void };
15
- };
16
- };
17
- };
18
- }
19
- }
20
-
21
- import {
22
- ArrowLeftIcon,
23
- Button,
24
- Checkbox,
25
- cls,
26
- IconButton,
27
- iconSize,
28
- LoadingButton,
29
- MailIcon,
30
- Menu,
31
- MenuItem,
32
- MoonIcon,
33
- SunIcon,
34
- SunMoonIcon,
35
- TextField,
36
- Typography
37
- } from "@rebasepro/ui";
38
- import { User } from "@rebasepro/types";
39
- import { AuthControllerExtended } from "@rebasepro/cms-types";
40
- import { ErrorView } from "../ErrorView";
41
- import { RebaseLogo } from "../RebaseLogo";
42
- import { LanguageToggle } from "../LanguageToggle";
43
- import { useModeController, useTranslation } from "../../hooks";
44
- import { consumeOAuthCallback, startOAuthRedirect } from "./oauth-redirect-flow";
45
-
46
- /**
47
- * Props for the generic LoginView.
48
- * Consumes {@link AuthControllerExtended} — works with any backend
49
- * (Rebase custom backend, Firebase, etc.)
50
- */
51
- export interface LoginViewProps {
52
- /**
53
- * Auth controller — must implement AuthControllerExtended.
54
- */
55
- authController: AuthControllerExtended;
56
-
57
- /**
58
- * Path to the logo displayed in the login screen
59
- */
60
- logo?: string;
61
-
62
- /**
63
- * Enable the skip login button
64
- */
65
- allowSkipLogin?: boolean;
66
-
67
- /**
68
- * Disable the login buttons
69
- */
70
- disabled?: boolean;
71
-
72
- /**
73
- * Prevent users from creating new accounts.
74
- * If not set, checks `authController.capabilities.registrationEnabled`.
75
- */
76
- disableSignupScreen?: boolean;
77
-
78
- /**
79
- * Display this component when no user is found
80
- */
81
- noUserComponent?: ReactNode;
82
-
83
- /**
84
- * Display this component below the sign-in buttons
85
- */
86
- additionalComponent?: ReactNode;
87
-
88
- /**
89
- * Display this component above the sign-in buttons
90
- */
91
- topComponent?: ReactNode;
92
-
93
- /**
94
- * Error message when user is not allowed access
95
- */
96
- notAllowedError?: string | Error;
97
-
98
-
99
- /**
100
- * Google client ID for Google OAuth.
101
- * Required when Google login is enabled via ID token flow.
102
- */
103
- googleClientId?: string;
104
-
105
- /**
106
- * GitHub client ID for GitHub OAuth.
107
- */
108
- githubClientId?: string;
109
-
110
- /**
111
- * LinkedIn client ID for LinkedIn OAuth.
112
- */
113
- linkedinClientId?: string;
114
-
115
- /**
116
- * Optional custom title shown above options
117
- */
118
- title?: string;
119
-
120
- /**
121
- * Optional custom subtitle shown above options
122
- */
123
- subtitle?: string;
124
-
125
- /**
126
- * When true, shows bootstrap/setup UI (first-user creation).
127
- * If not set, derived from `authController` if it exposes `needsSetup`.
128
- */
129
- needsSetup?: boolean;
130
-
131
- /**
132
- * Whether registration is enabled.
133
- * If not set, derived from `authController.capabilities.registrationEnabled`.
134
- */
135
- registrationEnabled?: boolean;
136
-
137
- /**
138
- * Pre-fill the email field (e.g. for demo or testing environments).
139
- */
140
- defaultEmail?: string;
141
-
142
- /**
143
- * Pre-fill the password field (e.g. for demo or testing environments).
144
- */
145
- defaultPassword?: string;
146
-
147
- /**
148
- * When set, a "Join the newsletter" opt-in checkbox is offered. Called with
149
- * the address that just authenticated — by any method — if the box was
150
- * ticked. Wire it to whatever stores the subscription; failures are the
151
- * callback's problem, the login flow never waits on it.
152
- *
153
- * The checkbox sits on the **provider screen**, beside the buttons that
154
- * choose how to sign in, rather than on the credentials form. It is a
155
- * decision about the visitor, not about the password: asking on the
156
- * credentials form put it in front of only the people who got that far by
157
- * typing an address, so anyone signing in with Google was never offered it
158
- * at all. Bootstrap mode is the exception — there is no provider screen
159
- * there, so the form keeps it.
160
- */
161
- onNewsletterOptIn?: (email: string) => void;
162
- }
163
-
164
- type AuthMode = "buttons" | "login" | "register" | "forgot";
165
-
166
- /**
167
- * The shared field background mixin (`dark:bg-black/30`) is invisible on the
168
- * near-black login card, so login inputs get an explicit border in dark mode.
169
- */
170
- const loginFieldClasses = "w-full dark:border dark:border-surface-700/70 dark:hover:border-surface-600";
171
-
172
- /** One warning per provider per page load — this is a config hint, not a log stream. */
173
- const warnedProviders = new Set<string>();
174
-
175
- /**
176
- * An OAuth button needs a client id in the frontend *and* the provider
177
- * configured on the backend. Half a configuration renders nothing at all, with
178
- * no error anywhere — the button simply is not there, which reads as "the
179
- * feature broke" rather than "one env var is missing". Say which half is
180
- * missing instead.
181
- */
182
- function warnAboutHalfConfiguredOAuth(
183
- provider: string,
184
- clientId: string | undefined,
185
- backendProviders: string[]
186
- ) {
187
- const backendHasIt = backendProviders.includes(provider);
188
- if (Boolean(clientId) === backendHasIt) return; // both or neither — nothing to say
189
- if (warnedProviders.has(provider)) return;
190
- warnedProviders.add(provider);
191
-
192
- const envVar = `${provider.toUpperCase()}_CLIENT_ID`;
193
- console.warn(
194
- clientId
195
- ? `[Rebase] ${provider} login is hidden: the login view has a client id, but the backend reports no "${provider}" provider. `
196
- + `Set ${envVar} (and ${provider.toUpperCase()}_CLIENT_SECRET) on the server and restart it.`
197
- : `[Rebase] ${provider} login is hidden: the backend has a "${provider}" provider, but the login view was given no client id. `
198
- + `Pass it to the login view (e.g. VITE_${envVar}) and restart the frontend so the new env var is picked up.`
199
- );
200
- }
201
-
202
- /**
203
- * Whether a provider's error code means "the visitor backed out" rather than
204
- * "the sign-in failed". Closing the popup is an answer, not a fault, and
205
- * showing it in red reads as a broken login.
206
- */
207
- function isUserCancellation(code: string | undefined): boolean {
208
- if (!code) return false;
209
- return [
210
- "access_denied",
211
- "popup_closed",
212
- "popup_closed_by_user",
213
- "user_cancel",
214
- "immediate_failed"
215
- ].includes(code);
216
- }
217
-
218
- /**
219
- * Generic login view component that works with any AuthControllerExtended.
220
- * Feature-detects capabilities to show/hide login methods.
221
- * @group Core
222
- */
223
- export function LoginView({
224
- logo,
225
- authController,
226
- noUserComponent,
227
- disableSignupScreen = false,
228
- disabled = false,
229
- notAllowedError,
230
- googleClientId,
231
- githubClientId,
232
- linkedinClientId,
233
- title,
234
- subtitle,
235
- needsSetup,
236
- registrationEnabled,
237
- additionalComponent,
238
- topComponent,
239
- defaultEmail,
240
- defaultPassword,
241
- onNewsletterOptIn
242
- }: LoginViewProps) {
243
-
244
- const modeState = useModeController();
245
- const { mode: colorMode, setMode: setColorMode } = modeState;
246
- const { t } = useTranslation();
247
-
248
- const [mode, setMode] = useState<AuthMode>("buttons");
249
- const [fadeIn, setFadeIn] = useState(false);
250
- const [viewVisible, setViewVisible] = useState(true);
251
- // Failures that never reach the auth controller: a provider that answers the
252
- // popup with an error, a redirect whose `state` does not match, a Google
253
- // script that never loaded. The controller records everything it is *given*
254
- // in `authProviderError`, so it cannot speak for the half of the flow that
255
- // happens before the code reaches it.
256
- const [providerError, setProviderError] = useState<string | null>(null);
257
- // Lives here, not in LoginForm: the box is ticked on the provider screen
258
- // and read after a sign-in that happens one or two screens later, so it has
259
- // to outlive both. The form also unmounts on every login↔register switch.
260
- const [newsletterOptIn, setNewsletterOptIn] = useState(false);
261
-
262
- // The controller as of the latest render. A provider callback resolves long
263
- // after the click that started it, and the `authController` captured in that
264
- // closure is the one from the render that mounted the button — which, for a
265
- // controller replaced on each render, has no `user` on it yet.
266
- const authControllerRef = useRef(authController);
267
- authControllerRef.current = authController;
268
-
269
- /**
270
- * Subscribe the address that just authenticated, if the visitor asked for
271
- * it. Every sign-in path calls this, which is the point: the checkbox sits
272
- * beside the provider buttons, so it has to mean the same thing whichever
273
- * one is pressed.
274
- *
275
- * Only ever from a resolution path — a ticked box on a *failed* attempt
276
- * must not subscribe an address nobody proved they control.
277
- */
278
- const subscribeIfOptedIn = useCallback((email?: string | null) => {
279
- if (!newsletterOptIn || !onNewsletterOptIn || !email) return;
280
- onNewsletterOptIn(email);
281
- }, [newsletterOptIn, onNewsletterOptIn]);
282
-
283
- const switchMode = (newMode: AuthMode) => {
284
- // A message about the last Google attempt has nothing to say about the
285
- // email form the visitor just opened.
286
- setProviderError(null);
287
- setViewVisible(false);
288
- setTimeout(() => {
289
- setMode(newMode);
290
- setViewVisible(true);
291
- }, 150);
292
- };
293
-
294
- // Resolve capabilities — explicit props override authController.capabilities
295
- const caps = authController.capabilities ?? {};
296
- const isBootstrapMode = needsSetup
297
- ?? ("needsSetup" in authController && !!(authController as { needsSetup?: boolean }).needsSetup)
298
- ?? false;
299
- const canRegister = registrationEnabled ?? caps.registrationEnabled ?? false;
300
- // Every OAuth button needs BOTH halves: a client id here and the matching
301
- // provider configured on the backend. `enabledProviders` is always an array
302
- // (the controller defaults it to `[]`), so a missing entry means "the
303
- // backend has no such provider" — never "unknown". A client id on its own
304
- // shows nothing, which is silent enough that `warnAboutHalfConfiguredOAuth`
305
- // below says so out loud.
306
- const backendProviders = caps.enabledProviders ?? [];
307
- const hasGoogleLogin = Boolean(googleClientId) && backendProviders.includes("google");
308
- const hasGitHubLogin = githubClientId && backendProviders.includes("github");
309
- const hasLinkedinLogin = linkedinClientId && backendProviders.includes("linkedin");
310
-
311
- useEffect(() => {
312
- // `enabledProviders` is empty until /auth/config lands, which looks
313
- // exactly like "the backend has no providers" — so wait for the value
314
- // to settle. A config that arrives late re-runs this effect and cancels
315
- // the pending warning.
316
- const timer = setTimeout(() => {
317
- warnAboutHalfConfiguredOAuth("google", googleClientId, backendProviders);
318
- warnAboutHalfConfiguredOAuth("github", githubClientId, backendProviders);
319
- warnAboutHalfConfiguredOAuth("linkedin", linkedinClientId, backendProviders);
320
- }, 3000);
321
- return () => clearTimeout(timer);
322
- }, [googleClientId, githubClientId, linkedinClientId, backendProviders.join(",")]);
323
- const hasPasswordReset = caps.passwordReset ?? !!authController.forgotPassword;
324
-
325
- const showRegistration = !disableSignupScreen && canRegister;
326
-
327
- useEffect(() => {
328
- const timer = setTimeout(() => setFadeIn(true), 50);
329
- return () => clearTimeout(timer);
330
- }, []);
331
-
332
- // Effect to handle incoming redirect OAuth codes (GitHub, LinkedIn, etc.).
333
- //
334
- // The `code` is only spent when it belongs to an authorization this browser
335
- // started: `consumeOAuthCallback` matches the returned `state` against the
336
- // one generated at button-click time. Without that, an attacker who starts
337
- // their own authorization, captures the code and gets the victim to load
338
- // `?code=<theirs>` hands the victim a session on the *attacker's* account.
339
- useEffect(() => {
340
- const result = consumeOAuthCallback(window.location.search);
341
- if (result.status === "none") return;
342
-
343
- // Clear URL search params without page reload
344
- const cleanUrl = window.location.origin + window.location.pathname;
345
- window.history.replaceState({}, document.title, cleanUrl);
346
-
347
- if (result.status === "error") {
348
- console.error(`OAuth sign-in failed: ${result.error}`);
349
- if (!isUserCancellation(result.error)) {
350
- setProviderError(`Sign-in failed: ${result.error}`);
351
- }
352
- return;
353
- }
354
- if (result.status === "mismatch") {
355
- console.error("Ignoring an OAuth code that does not match a sign-in this browser started.");
356
- setProviderError("This sign-in could not be verified as one you started in this browser. Please try again.");
357
- return;
358
- }
359
-
360
- if (authController.oauthLogin) {
361
- // The rejection is recorded in `authProviderError`, which the screen
362
- // renders — the catch is here so a shown error does not also surface
363
- // as an unhandled rejection.
364
- authController.oauthLogin(result.provider, {
365
- code: result.code,
366
- redirectUri: result.redirectUri,
367
- ...(result.codeVerifier ? { codeVerifier: result.codeVerifier } : {})
368
- }).catch((err: unknown) => {
369
- console.error(`${result.provider} login failed:`, err);
370
- });
371
- }
372
- }, [authController]);
373
-
374
-
375
-
376
- let logoComponent;
377
- if (logo) {
378
- logoComponent = <img src={logo}
379
- style={{
380
- height: "100%",
381
- width: "100%",
382
- objectFit: "cover"
383
- }}
384
- alt={"Logo"}/>;
385
- } else {
386
- logoComponent = <RebaseLogo/>;
387
- }
388
-
389
- // The controller's own record of the last rejected sign-in, ignored once a
390
- // user is present — a stale failure must not sit over a screen that has
391
- // since succeeded. `LoginForm` renders the same value for the email path.
392
- const controllerError = authController.authProviderError && !authController.user
393
- ? (authController.authProviderError instanceof Error
394
- ? authController.authProviderError.message
395
- : String(authController.authProviderError))
396
- : null;
397
- const buttonsErrorMessage = providerError ?? controllerError;
398
-
399
- let notAllowedMessage: string | undefined;
400
- if (notAllowedError) {
401
- if (typeof notAllowedError === "string") {
402
- notAllowedMessage = notAllowedError;
403
- } else if (notAllowedError instanceof Error) {
404
- notAllowedMessage = notAllowedError.message;
405
- } else {
406
- notAllowedMessage = "It looks like you don't have access, based on the specified access configuration";
407
- }
408
- }
409
-
410
- return (
411
- <div
412
- className={cls(
413
- "relative flex items-center justify-center h-screen w-screen p-4 transition-opacity duration-500 bg-surface-50 dark:bg-surface-900 overflow-hidden",
414
- fadeIn ? "opacity-100" : "opacity-0"
415
- )}>
416
-
417
- {/* Top-right controls */}
418
- <div className="absolute top-4 right-4 flex items-center gap-1 z-10">
419
- <LanguageToggle/>
420
- <Menu
421
- trigger={<IconButton
422
- color="inherit"
423
- aria-label="Toggle theme">
424
- {colorMode === "dark"
425
- ? <MoonIcon size={iconSize.small}/>
426
- : <SunIcon size={iconSize.small}/>}
427
- </IconButton>}>
428
- <MenuItem onClick={() => setColorMode("dark")}><MoonIcon size={iconSize.smallest}/> {t("dark_mode")}</MenuItem>
429
- <MenuItem onClick={() => setColorMode("light")}><SunIcon size={iconSize.smallest}/> {t("light_mode")}</MenuItem>
430
- <MenuItem onClick={() => setColorMode("system")}><SunMoonIcon size={iconSize.smallest}/> {t("system_mode")}</MenuItem>
431
- </Menu>
432
- </div>
433
-
434
- <div className="relative flex flex-col items-center w-[440px] max-w-full p-8 sm:p-10 bg-white/70 dark:bg-surface-800/70 backdrop-blur-xl border border-surface-200/50 dark:border-surface-700/50 rounded-2xl shadow-2xl z-10 transition-all duration-300 hover:shadow-primary-500/5">
435
- {/* Logo */}
436
- <div className="w-24 h-24 m-2 mb-4 drop-shadow-md">
437
- {logoComponent}
438
- </div>
439
-
440
- {notAllowedMessage && (
441
- <div className="p-4 w-full">
442
- <ErrorView error={notAllowedMessage}/>
443
- </div>
444
- )}
445
-
446
- <div className={cls(
447
- "w-full transition-opacity duration-150",
448
- viewVisible ? "opacity-100" : "opacity-0"
449
- )}>
450
-
451
- {/* Bootstrap mode: show setup form directly */}
452
- {isBootstrapMode && !authController.user && (
453
- <LoginForm
454
- authController={authController}
455
- registrationMode={true}
456
- onClose={() => {}}
457
- onForgotPassword={() => {}}
458
- disableSignupScreen={false}
459
- bootstrapMode={true}
460
- defaultEmail={defaultEmail}
461
- defaultPassword={defaultPassword}
462
- onSignedIn={subscribeIfOptedIn}
463
- newsletterOptIn={newsletterOptIn}
464
- setNewsletterOptIn={setNewsletterOptIn}
465
- showNewsletterOptIn={Boolean(onNewsletterOptIn)}
466
- />
467
- )}
468
-
469
- {/* Normal mode */}
470
- {!isBootstrapMode && (
471
- <>
472
- {/* Provider buttons screen */}
473
- {mode === "buttons" && (
474
- <div className="w-full flex flex-col gap-3 mt-2">
475
- {/* Every provider button fails here, and this
476
- screen used to render nothing for it: a
477
- refused Google sign-in closed the popup
478
- and returned the visitor to an unchanged
479
- login screen, with the reason only in the
480
- browser console. */}
481
- {buttonsErrorMessage && (
482
- <ErrorView error={buttonsErrorMessage}/>
483
- )}
484
- {(title || subtitle) && (
485
- <div className="text-center mb-2">
486
- {title && (
487
- <Typography variant="h6" className="mb-0.5 font-semibold">
488
- {title}
489
- </Typography>
490
- )}
491
- {subtitle && (
492
- <Typography variant="body2" color="secondary" className="mb-4">
493
- {subtitle}
494
- </Typography>
495
- )}
496
- </div>
497
- )}
498
- {topComponent && (
499
- <div className="w-full">
500
- {topComponent}
501
- </div>
502
- )}
503
- {/* Above the buttons, not below them: it is a
504
- condition on the sign-in you are about to
505
- make, and it reads as one more line of the
506
- consent block a host puts in
507
- `topComponent` — the demo's privacy tick
508
- sits directly above it.
509
-
510
- Which is why it pulls back most of the
511
- column's `gap-3`: at the full gap the two
512
- ticks sat 24px apart and read as two
513
- unrelated questions rather than one
514
- consent block. The offset is against this
515
- column's own gap, not against whatever
516
- margins the host's `topComponent` happens
517
- to carry, so it lands the same way under
518
- any of them — but only when there is
519
- something above to hug. With no header
520
- and no `topComponent` this row is the
521
- column's first child, and pulling back a
522
- gap that is not there would crowd it into
523
- the logo. */}
524
- {onNewsletterOptIn && (
525
- <label className={cls(
526
- "flex items-center gap-2 cursor-pointer",
527
- (topComponent || title || subtitle) && "-mt-3"
528
- )}>
529
- <Checkbox
530
- checked={newsletterOptIn}
531
- onCheckedChange={(checked) => setNewsletterOptIn(checked === true)}
532
- size="small"
533
- />
534
- <Typography variant="caption" color="secondary" className="select-none">
535
- {t("join_newsletter")}
536
- </Typography>
537
- </label>
538
- )}
539
- <LoginButton
540
- disabled={disabled}
541
- text={"Sign in with email"}
542
- icon={<MailIcon/>}
543
- onClick={() => switchMode("login")}
544
- />
545
- {hasGoogleLogin && googleClientId && (
546
- <GoogleLoginButton
547
- disabled={disabled}
548
- googleClientId={googleClientId}
549
- authController={authController}
550
- onSignedIn={() => subscribeIfOptedIn(authControllerRef.current.user?.email)}
551
- onError={setProviderError}
552
- />
553
- )}
554
- {hasGitHubLogin && githubClientId && (
555
- <GitHubLoginButton
556
- disabled={disabled}
557
- githubClientId={githubClientId}
558
- />
559
- )}
560
- {hasLinkedinLogin && linkedinClientId && (
561
- <LinkedInLoginButton
562
- disabled={disabled}
563
- linkedinClientId={linkedinClientId}
564
- />
565
- )}
566
- {showRegistration && (
567
- <div className="mt-2 text-center">
568
- <Typography variant="body2" color="secondary">
569
- Don&apos;t have an account?{" "}
570
- <button
571
- type="button"
572
- className="font-semibold hover:underline cursor-pointer text-primary-600 dark:text-primary-400"
573
- onClick={() => switchMode("register")}
574
- >
575
- Create one
576
- </button>
577
- </Typography>
578
- </div>
579
- )}
580
- </div>
581
- )}
582
-
583
- {/* Login form */}
584
- {mode === "login" && (
585
- <LoginForm
586
- authController={authController}
587
- registrationMode={false}
588
- onClose={() => switchMode("buttons")}
589
- onForgotPassword={hasPasswordReset ? () => switchMode("forgot") : undefined}
590
- noUserComponent={noUserComponent}
591
- disableSignupScreen={disableSignupScreen}
592
- switchToRegister={showRegistration ? () => switchMode("register") : undefined}
593
- defaultEmail={defaultEmail}
594
- defaultPassword={defaultPassword}
595
- onSignedIn={subscribeIfOptedIn}
596
- />
597
- )}
598
-
599
- {/* Registration form */}
600
- {mode === "register" && (
601
- <LoginForm
602
- authController={authController}
603
- registrationMode={true}
604
- onClose={() => switchMode("buttons")}
605
- onForgotPassword={hasPasswordReset ? () => switchMode("forgot") : undefined}
606
- noUserComponent={noUserComponent}
607
- disableSignupScreen={disableSignupScreen}
608
- switchToLogin={() => switchMode("login")}
609
- defaultEmail={defaultEmail}
610
- defaultPassword={defaultPassword}
611
- onSignedIn={subscribeIfOptedIn}
612
- />
613
- )}
614
-
615
- {/* Forgot password form */}
616
- {mode === "forgot" && authController.forgotPassword && (
617
- <ForgotPasswordForm
618
- authController={authController}
619
- onClose={() => switchMode("login")}
620
- />
621
- )}
622
- </>
623
- )}
624
- </div>
625
-
626
- {additionalComponent && !isBootstrapMode && mode === "buttons" && (
627
- <div className="w-full">
628
- {additionalComponent}
629
- </div>
630
- )}
631
- </div>
632
- </div>
633
- );
634
- }
635
-
636
- function LoginButton({
637
- icon,
638
- onClick,
639
- text,
640
- disabled
641
- }: { icon: React.ReactNode, onClick: () => void, text: string, disabled?: boolean }) {
642
- return (
643
- <Button
644
- disabled={disabled}
645
- className="w-full transition-transform duration-200 active:scale-[0.98]"
646
- variant="outlined"
647
- size="large"
648
- onClick={onClick}>
649
- <div className="flex items-center justify-center w-full gap-3 py-1">
650
- <span className="flex items-center justify-center w-5 h-5">
651
- {icon}
652
- </span>
653
- <Typography variant="button">{text}</Typography>
654
- </div>
655
- </Button>
656
- );
657
- }
658
-
659
- const GoogleIcon = () => (
660
- <svg viewBox="0 0 24 24" width="20" height="20">
661
- <path fill="#4285F4"
662
- d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
663
- <path fill="#34A853"
664
- d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
665
- <path fill="#FBBC05"
666
- d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
667
- <path fill="#EA4335"
668
- d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
669
- </svg>
670
- );
671
-
672
- function GoogleLoginButton({
673
- disabled,
674
- googleClientId,
675
- authController,
676
- onSignedIn,
677
- onError
678
- }: {
679
- disabled?: boolean,
680
- googleClientId: string,
681
- authController: AuthControllerExtended,
682
- /** Called once the controller has accepted the provider's code. */
683
- onSignedIn?: () => void,
684
- /**
685
- * Reports a failure the auth controller never saw — Google answering the
686
- * popup with an error, or its script never having loaded. A rejected
687
- * `googleLogin` is not reported here: the controller already records it.
688
- */
689
- onError?: (message: string | null) => void
690
- }) {
691
- const codeClientRef = useRef<{ requestCode(): void } | null>(null);
692
- // The code client is built once and its callback closes over this render's
693
- // props forever. A ref keeps the callback reaching the *current* handler
694
- // rather than the one that existed when Google's script happened to load.
695
- const onSignedInRef = useRef(onSignedIn);
696
- onSignedInRef.current = onSignedIn;
697
- const onErrorRef = useRef(onError);
698
- onErrorRef.current = onError;
699
-
700
- useEffect(() => {
701
- if (!authController.googleLogin) return;
702
-
703
- const google = window.google;
704
- if (!google || codeClientRef.current) return;
705
-
706
- codeClientRef.current = google.accounts.oauth2.initCodeClient({
707
- client_id: googleClientId,
708
- scope: "openid email profile",
709
- ux_mode: "popup",
710
- callback: async (response: { code?: string; error?: string }) => {
711
- if (response.error || !response.code) {
712
- console.error("Google login error:", response.error);
713
- if (!isUserCancellation(response.error)) {
714
- onErrorRef.current?.(
715
- response.error
716
- ? `Google sign-in failed: ${response.error}`
717
- : "Google sign-in failed. Please try again."
718
- );
719
- }
720
- return;
721
- }
722
- onErrorRef.current?.(null);
723
- try {
724
- // Send the authorization code to the backend.
725
- // redirectUri "postmessage" is required when using popup ux_mode.
726
- await authController.googleLogin!({
727
- code: response.code,
728
- redirectUri: "postmessage"
729
- });
730
- // After the await, and only on the resolution path: a
731
- // newsletter tick must not subscribe an address whose
732
- // sign-in the controller rejected.
733
- onSignedInRef.current?.();
734
- } catch (err: unknown) {
735
- console.error("Google login error:", err);
736
- }
737
- }
738
- });
739
- }, [googleClientId, authController]);
740
-
741
- const handleClick = () => {
742
- if (!codeClientRef.current) {
743
- console.error("Google Sign-In not loaded");
744
- onErrorRef.current?.("Google Sign-In could not be loaded. Check your connection or any script blockers, then try again.");
745
- return;
746
- }
747
- onErrorRef.current?.(null);
748
- codeClientRef.current.requestCode();
749
- };
750
-
751
- return (
752
- <LoginButton
753
- disabled={disabled}
754
- text="Sign in with Google"
755
- icon={<GoogleIcon/>}
756
- onClick={handleClick}
757
- />
758
- );
759
- }
760
-
761
- const GitHubIcon = () => (
762
- <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
763
- <path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.579.688.481C19.137 20.162 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
764
- </svg>
765
- );
766
-
767
- function GitHubLoginButton({
768
- disabled,
769
- githubClientId
770
- }: {
771
- disabled?: boolean,
772
- githubClientId: string
773
- }) {
774
- const handleClick = () => {
775
- // GitHub OAuth Apps do not implement PKCE, so `state` is the whole
776
- // binding between this click and the code that comes back.
777
- void startOAuthRedirect({
778
- provider: "github",
779
- authorizeUrl: "https://github.com/login/oauth/authorize",
780
- clientId: githubClientId,
781
- redirectUri: window.location.origin + window.location.pathname,
782
- scope: "read:user,user:email"
783
- });
784
- };
785
-
786
- return (
787
- <LoginButton
788
- disabled={disabled}
789
- text="Sign in with GitHub"
790
- icon={<GitHubIcon/>}
791
- onClick={handleClick}
792
- />
793
- );
794
- }
795
-
796
- const LinkedInIcon = () => (
797
- <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
798
- <path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
799
- </svg>
800
- );
801
-
802
- function LinkedInLoginButton({
803
- disabled,
804
- linkedinClientId
805
- }: {
806
- disabled?: boolean,
807
- linkedinClientId: string
808
- }) {
809
- const handleClick = () => {
810
- void startOAuthRedirect({
811
- provider: "linkedin",
812
- authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
813
- clientId: linkedinClientId,
814
- redirectUri: window.location.origin + window.location.pathname,
815
- scope: "openid profile email"
816
- });
817
- };
818
-
819
- return (
820
- <LoginButton
821
- disabled={disabled}
822
- text="Sign in with LinkedIn"
823
- icon={<LinkedInIcon/>}
824
- onClick={handleClick}
825
- />
826
- );
827
- }
828
-
829
- function LoginForm({
830
- onClose,
831
- onForgotPassword,
832
- authController,
833
- registrationMode,
834
- noUserComponent,
835
- disableSignupScreen,
836
- bootstrapMode = false,
837
- switchToRegister,
838
- switchToLogin,
839
- defaultEmail,
840
- defaultPassword,
841
- onSignedIn,
842
- newsletterOptIn = false,
843
- setNewsletterOptIn,
844
- showNewsletterOptIn = false
845
- }: {
846
- onClose: () => void,
847
- onForgotPassword?: () => void,
848
- authController: AuthControllerExtended,
849
- registrationMode: boolean,
850
- noUserComponent?: ReactNode,
851
- disableSignupScreen: boolean,
852
- bootstrapMode?: boolean,
853
- switchToRegister?: () => void,
854
- switchToLogin?: () => void,
855
- defaultEmail?: string,
856
- defaultPassword?: string,
857
- /** The address the controller just accepted, reported after every success. */
858
- onSignedIn?: (email: string) => void,
859
- newsletterOptIn?: boolean,
860
- setNewsletterOptIn?: (checked: boolean) => void,
861
- /**
862
- * Render the newsletter opt-in here. Only bootstrap mode does: everywhere
863
- * else it lives on the provider screen, which bootstrap mode never shows.
864
- */
865
- showNewsletterOptIn?: boolean
866
- }) {
867
- const passwordRef = useRef<HTMLInputElement | null>(null);
868
- const { t } = useTranslation();
869
-
870
- // `Typography variant="label"` already renders a real <label> element, it just
871
- // never pointed at anything, so each field fell back to naming itself after its
872
- // own placeholder — the password field announced as eight bullets. Associating
873
- // the existing labels beats passing the kit's `label` prop here: that one moves
874
- // the text inside the box (`pt-6 pb-2` at this size) and hides the placeholder
875
- // until focus, which would redesign the login screen to fix a name.
876
- const emailId = useId();
877
- const passwordId = useId();
878
- const displayNameId = useId();
879
-
880
- const [email, setEmail] = useState<string | undefined>(defaultEmail);
881
- const [password, setPassword] = useState<string | undefined>(defaultPassword);
882
- const [displayName, setDisplayName] = useState<string>();
883
-
884
- useEffect(() => {
885
- if (!document) return;
886
- const escFunction = (event: KeyboardEvent) => {
887
- if (event.key === "Escape") {
888
- onClose();
889
- }
890
- };
891
- document.addEventListener("keydown", escFunction, false);
892
- return () => {
893
- document.removeEventListener("keydown", escFunction, false);
894
- };
895
- }, [onClose]);
896
-
897
- // Both controller methods record the failure in `authProviderError` — which
898
- // this form renders above — and then rethrow for programmatic callers. This
899
- // is not one of those: without a catch here every rejected sign-in becomes an
900
- // unhandled rejection, reported as a crash by anything listening for one,
901
- // while the user has already been shown the error.
902
- // Fires only on the resolution path, i.e. after the controller accepted
903
- // the credentials — a ticked box on a failed attempt must not subscribe
904
- // an address its owner never proved they control.
905
- function reportSignedIn(email: string) {
906
- onSignedIn?.(email);
907
- }
908
-
909
- function handleEnterPassword() {
910
- if (email && password && authController.emailPasswordLogin) {
911
- void Promise.resolve(authController.emailPasswordLogin(email, password))
912
- .then(() => reportSignedIn(email))
913
- .catch(() => undefined);
914
- }
915
- }
916
-
917
- function handleRegistration() {
918
- if (email && password && authController.register) {
919
- void Promise.resolve(authController.register(email, password, displayName))
920
- .then(() => reportSignedIn(email))
921
- .catch(() => undefined);
922
- }
923
- }
924
-
925
- const handleSubmit = (event: React.FormEvent) => {
926
- event.preventDefault();
927
- if (registrationMode)
928
- handleRegistration();
929
- else
930
- handleEnterPassword();
931
- };
932
-
933
- const title = bootstrapMode
934
- ? "Welcome!"
935
- : registrationMode
936
- ? "Create account"
937
- : "Sign in";
938
-
939
- const subtitle = bootstrapMode
940
- ? "Create your admin account to get started. This account will have admin privileges."
941
- : registrationMode
942
- ? "Fill in your details to create a new account"
943
- : "Enter your credentials to continue";
944
-
945
- const buttonLabel = registrationMode ? "Create account" : "Sign in";
946
-
947
- return (
948
- <form onSubmit={handleSubmit} className="flex flex-col w-full gap-1 mt-2">
949
- {!bootstrapMode && (
950
- <div className="w-full mb-2 -ml-2.5">
951
- <IconButton onClick={onClose}>
952
- <ArrowLeftIcon/>
953
- </IconButton>
954
- </div>
955
- )}
956
-
957
- {(() => {
958
- const err = authController.authProviderError;
959
- if (!err || authController.user) return null;
960
- const msg: string = err instanceof Error ? err.message : String(err);
961
- return (
962
- <div className="w-full mb-2">
963
- <ErrorView error={msg}/>
964
- </div>
965
- );
966
- })()}
967
-
968
- <Typography variant="h6" className="mb-0.5">
969
- {title}
970
- </Typography>
971
- <Typography variant="body2" color="secondary" className="mb-5">
972
- {subtitle}
973
- </Typography>
974
-
975
- {registrationMode && !bootstrapMode && noUserComponent && (
976
- <div className="w-full mb-2">
977
- {noUserComponent}
978
- </div>
979
- )}
980
-
981
- {registrationMode && (
982
- <div className="w-full mb-3">
983
- <Typography variant="label" component="label" color="secondary" className="mb-1" htmlFor={displayNameId}>
984
- Display Name
985
- </Typography>
986
- <TextField placeholder="Jane Doe (optional)"
987
- id={displayNameId}
988
- className={loginFieldClasses}
989
- value={displayName ?? ""}
990
- disabled={authController.initialLoading}
991
- type="text"
992
- size="medium"
993
- onChange={(event) => setDisplayName(event.target.value)}/>
994
- </div>
995
- )}
996
-
997
- <div className="w-full mb-3">
998
- <Typography variant="label" component="label" color="secondary" className="mb-1" htmlFor={emailId}>
999
- Email
1000
- </Typography>
1001
- <TextField placeholder="you@example.com"
1002
- id={emailId}
1003
- className={loginFieldClasses}
1004
- autoFocus
1005
- value={email ?? ""}
1006
- disabled={authController.initialLoading}
1007
- type="email"
1008
- size="medium"
1009
- onChange={(event) => setEmail(event.target.value)}/>
1010
- </div>
1011
-
1012
- <div className="w-full mb-1">
1013
- <Typography variant="label" component="label" color="secondary" className="mb-1" htmlFor={passwordId}>
1014
- Password
1015
- </Typography>
1016
- <TextField placeholder="••••••••"
1017
- id={passwordId}
1018
- className={loginFieldClasses}
1019
- value={password ?? ""}
1020
- disabled={authController.initialLoading}
1021
- inputRef={passwordRef}
1022
- type="password"
1023
- size="medium"
1024
- onChange={(event) => setPassword(event.target.value)}/>
1025
- </div>
1026
-
1027
- {registrationMode && (
1028
- <Typography variant="caption" color="secondary" className="mb-3">
1029
- Password must be 8+ characters with uppercase, lowercase, and a number
1030
- </Typography>
1031
- )}
1032
-
1033
- {!registrationMode && onForgotPassword && (
1034
- <div className="w-full text-right mb-3">
1035
- <button
1036
- type="button"
1037
- className="text-xs font-medium hover:underline cursor-pointer text-primary-600 dark:text-primary-400"
1038
- onClick={onForgotPassword}
1039
- >
1040
- Forgot password?
1041
- </button>
1042
- </div>
1043
- )}
1044
-
1045
- {/* Bootstrap mode only — see `showNewsletterOptIn`. Every other
1046
- route to this form came through the provider screen, which is
1047
- where the box now is. */}
1048
- {showNewsletterOptIn && (
1049
- <label className="flex items-center gap-2 cursor-pointer mt-1 mb-1">
1050
- <Checkbox
1051
- checked={newsletterOptIn}
1052
- onCheckedChange={(checked) => setNewsletterOptIn?.(checked === true)}
1053
- size="small"
1054
- />
1055
- <Typography variant="caption" color="secondary" className="select-none">
1056
- {t("join_newsletter")}
1057
- </Typography>
1058
- </label>
1059
- )}
1060
-
1061
- <LoadingButton
1062
- type="submit"
1063
- variant="filled"
1064
- color="primary"
1065
- className="w-full mt-1"
1066
- size="large"
1067
- loading={authController.authLoading}
1068
- disabled={authController.authLoading || !email || !password}
1069
- >
1070
- {buttonLabel}
1071
- </LoadingButton>
1072
-
1073
- {/* Switch between login/register */}
1074
- {switchToRegister && (
1075
- <div className="mt-4 text-center">
1076
- <Typography variant="body2" color="secondary">
1077
- Don&apos;t have an account?{" "}
1078
- <button
1079
- type="button"
1080
- className="font-semibold hover:underline cursor-pointer text-primary-600 dark:text-primary-400"
1081
- onClick={switchToRegister}
1082
- >
1083
- Create one
1084
- </button>
1085
- </Typography>
1086
- </div>
1087
- )}
1088
-
1089
- {switchToLogin && (
1090
- <div className="mt-4 text-center">
1091
- <Typography variant="body2" color="secondary">
1092
- Already have an account?{" "}
1093
- <button
1094
- type="button"
1095
- className="font-semibold hover:underline cursor-pointer text-primary-600 dark:text-primary-400"
1096
- onClick={switchToLogin}
1097
- >
1098
- Sign in
1099
- </button>
1100
- </Typography>
1101
- </div>
1102
- )}
1103
- </form>
1104
- );
1105
- }
1106
-
1107
- function ForgotPasswordForm({
1108
- onClose,
1109
- authController
1110
- }: {
1111
- onClose: () => void,
1112
- authController: AuthControllerExtended
1113
- }) {
1114
- const [email, setEmail] = useState<string>("");
1115
- const [submitted, setSubmitted] = useState(false);
1116
- const [error, setError] = useState<string | null>(null);
1117
- const emailId = useId();
1118
-
1119
- useEffect(() => {
1120
- if (!document) return;
1121
- const escFunction = (event: KeyboardEvent) => {
1122
- if (event.key === "Escape") {
1123
- onClose();
1124
- }
1125
- };
1126
- document.addEventListener("keydown", escFunction, false);
1127
- return () => {
1128
- document.removeEventListener("keydown", escFunction, false);
1129
- };
1130
- }, [onClose]);
1131
-
1132
- const handleSubmit = async (event: React.FormEvent) => {
1133
- event.preventDefault();
1134
- setError(null);
1135
-
1136
- if (!email) {
1137
- setError("Please enter your email address");
1138
- return;
1139
- }
1140
-
1141
- if (!authController.forgotPassword) {
1142
- setError("Password reset is not available with this auth provider.");
1143
- return;
1144
- }
1145
-
1146
- try {
1147
- await authController.forgotPassword(email);
1148
- setSubmitted(true);
1149
- } catch (err: unknown) {
1150
- // Check for EMAIL_NOT_CONFIGURED error
1151
- if (err instanceof Error && (err as { code?: string }).code === "EMAIL_NOT_CONFIGURED") {
1152
- setError("Password reset is not available. Please contact your administrator.");
1153
- } else {
1154
- // Still show success (security: don't reveal if email exists)
1155
- setSubmitted(true);
1156
- }
1157
- }
1158
- };
1159
-
1160
- if (submitted) {
1161
- return (
1162
- <div className="flex flex-col w-full gap-4 mt-2">
1163
- <div className="w-full -ml-2.5">
1164
- <IconButton onClick={onClose}>
1165
- <ArrowLeftIcon/>
1166
- </IconButton>
1167
- </div>
1168
-
1169
- <div className="text-center rounded-xl p-6 bg-surface-50 dark:bg-surface-900/60 dark:border dark:border-surface-700/50">
1170
- <div className="text-3xl mb-3">📧</div>
1171
- <Typography variant="subtitle1" className="mb-2">
1172
- Check your email
1173
- </Typography>
1174
- <Typography variant="body2" color="secondary">
1175
- If an account exists for <strong>{email}</strong>, you&apos;ll receive a password reset link shortly.
1176
- </Typography>
1177
- </div>
1178
-
1179
- <Button onClick={onClose} variant="text" className="mt-2">
1180
- Back to sign in
1181
- </Button>
1182
- </div>
1183
- );
1184
- }
1185
-
1186
- return (
1187
- <form onSubmit={handleSubmit} className="flex flex-col w-full gap-1 mt-2">
1188
- <div className="w-full mb-2 -ml-2.5">
1189
- <IconButton onClick={onClose}>
1190
- <ArrowLeftIcon/>
1191
- </IconButton>
1192
- </div>
1193
-
1194
- <Typography variant="h6" className="mb-0.5">
1195
- Reset password
1196
- </Typography>
1197
- <Typography variant="body2" color="secondary" className="mb-5">
1198
- Enter your email and we&apos;ll send you a reset link.
1199
- </Typography>
1200
-
1201
- {error && (
1202
- <div className="w-full mb-3">
1203
- <ErrorView error={error}/>
1204
- </div>
1205
- )}
1206
-
1207
- <div className="w-full mb-3">
1208
- <Typography variant="label" component="label" color="secondary" className="mb-1" htmlFor={emailId}>
1209
- Email
1210
- </Typography>
1211
- <TextField
1212
- placeholder="you@example.com"
1213
- id={emailId}
1214
- className={loginFieldClasses}
1215
- autoFocus
1216
- value={email}
1217
- type="email"
1218
- size="medium"
1219
- onChange={(event) => setEmail(event.target.value)}
1220
- />
1221
- </div>
1222
-
1223
- <LoadingButton
1224
- type="submit"
1225
- variant="filled"
1226
- className="w-full"
1227
- size="large"
1228
- loading={authController.authLoading}
1229
- disabled={authController.authLoading || !email}
1230
- >
1231
- Send reset link
1232
- </LoadingButton>
1233
- </form>
1234
- );
1235
- }