@rebasepro/auth 0.0.1-canary.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.
@@ -0,0 +1,570 @@
1
+ import React, { ReactNode, useEffect, useRef, useState } from "react";
2
+
3
+ import { ArrowBackIcon, Button, CircularProgress, IconButton, MailIcon, TextField, Typography } from "@rebasepro/ui";
4
+ import { ErrorView, RebaseLogo, useModeController } from "@rebasepro/core";
5
+
6
+ import { RebaseAuthController } from "../types";
7
+
8
+ /**
9
+ * Props for RebaseLoginView
10
+ */
11
+ export interface RebaseLoginViewProps {
12
+ /**
13
+ * Auth controller from useRebaseAuthController
14
+ */
15
+ authController: RebaseAuthController;
16
+
17
+ /**
18
+ * Path to the logo displayed in the login screen
19
+ */
20
+ logo?: string;
21
+
22
+ /**
23
+ * Enable the skip login button
24
+ */
25
+ allowSkipLogin?: boolean;
26
+
27
+ /**
28
+ * Disable the login buttons
29
+ */
30
+ disabled?: boolean;
31
+
32
+ /**
33
+ * Prevent users from creating new accounts
34
+ */
35
+ disableSignupScreen?: boolean;
36
+
37
+ /**
38
+ * Display this component when no user is found
39
+ */
40
+ noUserComponent?: ReactNode;
41
+
42
+ /**
43
+ * Display this component below the sign-in buttons
44
+ */
45
+ additionalComponent?: ReactNode;
46
+
47
+ /**
48
+ * Error message when user is not allowed access
49
+ */
50
+ notAllowedError?: string | Error;
51
+
52
+ /**
53
+ * Enable Google login button (requires googleClientId in hook)
54
+ */
55
+ googleEnabled?: boolean;
56
+
57
+ /**
58
+ * Google client ID for OAuth
59
+ */
60
+ googleClientId?: string;
61
+ }
62
+
63
+ /**
64
+ * Login view component for custom JWT authentication
65
+ * Based on MongoLoginView pattern from @rebasepro/mongodb
66
+ */
67
+ export function RebaseLoginView({
68
+ logo,
69
+ authController,
70
+ noUserComponent,
71
+ disableSignupScreen = false,
72
+ disabled = false,
73
+ notAllowedError,
74
+ googleEnabled = false,
75
+ googleClientId
76
+ }: RebaseLoginViewProps) {
77
+
78
+ const modeState = useModeController();
79
+
80
+ const [registrationSelected, setRegistrationSelected] = useState(false);
81
+ const [passwordLoginSelected, setPasswordLoginSelected] = useState(false);
82
+ const [forgotPasswordSelected, setForgotPasswordSelected] = useState(false);
83
+
84
+ // Auto-show setup form when no users exist (bootstrap mode)
85
+ const isBootstrapMode = authController.needsSetup;
86
+
87
+ function buildErrorView() {
88
+ if (!authController.authProviderError) return null;
89
+ if (authController.user != null) return null;
90
+ return <ErrorView error={authController.authProviderError.message ?? authController.authProviderError} />;
91
+ }
92
+
93
+ let logoComponent;
94
+ if (logo) {
95
+ logoComponent = <img src={logo}
96
+ style={{
97
+ height: "100%",
98
+ width: "100%",
99
+ objectFit: "cover"
100
+ }}
101
+ alt={"Logo"} />;
102
+ } else {
103
+ logoComponent = <RebaseLogo />;
104
+ }
105
+
106
+ let notAllowedMessage: string | undefined;
107
+ if (notAllowedError) {
108
+ if (typeof notAllowedError === "string") {
109
+ notAllowedMessage = notAllowedError;
110
+ } else if (notAllowedError instanceof Error) {
111
+ notAllowedMessage = notAllowedError.message;
112
+ } else {
113
+ notAllowedMessage = "It looks like you don't have access to the CMS, based on the specified Authenticator configuration";
114
+ }
115
+ }
116
+
117
+ return (
118
+ <div className="flex flex-col justify-center items-center min-h-screen min-w-full p-2">
119
+ <div className="flex flex-col items-center w-full max-w-md">
120
+ <div className={`m-4 p-4 w-64 h-64`}>
121
+ {logoComponent}
122
+ </div>
123
+ {notAllowedMessage &&
124
+ <div className="p-4">
125
+ <ErrorView error={notAllowedMessage} />
126
+ </div>
127
+ }
128
+ {!forgotPasswordSelected && buildErrorView()}
129
+
130
+ {/* Bootstrap mode: show setup form directly */}
131
+ {isBootstrapMode && !authController.user && (
132
+ <LoginForm
133
+ authController={authController}
134
+ registrationMode={true}
135
+ onClose={() => {}}
136
+ onForgotPassword={() => {}}
137
+ mode={modeState.mode}
138
+ noUserComponent={noUserComponent}
139
+ disableSignupScreen={false}
140
+ bootstrapMode={true}
141
+ />
142
+ )}
143
+
144
+ {/* Normal mode: show login/register buttons */}
145
+ {!isBootstrapMode && !passwordLoginSelected && !registrationSelected && !forgotPasswordSelected && (
146
+ <>
147
+ <LoginButton
148
+ disabled={disabled}
149
+ text={"Email/password"}
150
+ icon={<MailIcon />}
151
+ onClick={() => {
152
+ setRegistrationSelected(false);
153
+ setPasswordLoginSelected(true);
154
+ setForgotPasswordSelected(false);
155
+ }}
156
+ />
157
+ {googleEnabled && googleClientId && (
158
+ <GoogleLoginButton
159
+ disabled={disabled}
160
+ googleClientId={googleClientId}
161
+ authController={authController}
162
+ />
163
+ )}
164
+ {!disableSignupScreen && authController.registrationEnabled && (
165
+ <LoginButton
166
+ disabled={disabled}
167
+ text={"Create account"}
168
+ icon={<MailIcon />}
169
+ onClick={() => {
170
+ setRegistrationSelected(true);
171
+ setPasswordLoginSelected(false);
172
+ setForgotPasswordSelected(false);
173
+ }}
174
+ />
175
+ )}
176
+ </>
177
+ )}
178
+ {!isBootstrapMode && (passwordLoginSelected || registrationSelected) && !forgotPasswordSelected && (
179
+ <LoginForm
180
+ authController={authController}
181
+ registrationMode={registrationSelected}
182
+ onClose={() => {
183
+ setRegistrationSelected(false);
184
+ setPasswordLoginSelected(false);
185
+ }}
186
+ onForgotPassword={() => {
187
+ setForgotPasswordSelected(true);
188
+ setPasswordLoginSelected(false);
189
+ }}
190
+ mode={modeState.mode}
191
+ noUserComponent={noUserComponent}
192
+ disableSignupScreen={disableSignupScreen}
193
+ />
194
+ )}
195
+ {forgotPasswordSelected && (
196
+ <ForgotPasswordForm
197
+ authController={authController}
198
+ onClose={() => {
199
+ setForgotPasswordSelected(false);
200
+ setPasswordLoginSelected(true);
201
+ }}
202
+ />
203
+ )}
204
+ </div>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ function LoginButton({
210
+ icon,
211
+ onClick,
212
+ text,
213
+ disabled
214
+ }: { icon: React.ReactNode, onClick: () => void, text: string, disabled?: boolean }) {
215
+ return (
216
+ <div className="m-2 w-full">
217
+ <Button
218
+ disabled={disabled}
219
+ className={`w-full`}
220
+ onClick={onClick}>
221
+ <div className="flex items-center justify-center p-2 w-full h-8">
222
+ <div className="flex flex-col items-center justify-center w-8">
223
+ {icon}
224
+ </div>
225
+ <div className="grow pl-2 text-center">
226
+ {text}
227
+ </div>
228
+ </div>
229
+ </Button>
230
+ </div>
231
+ )
232
+ }
233
+
234
+ function GoogleLoginButton({
235
+ disabled,
236
+ googleClientId,
237
+ authController
238
+ }: {
239
+ disabled?: boolean,
240
+ googleClientId: string,
241
+ authController: RebaseAuthController
242
+ }) {
243
+ const handleGoogleLogin = async () => {
244
+ try {
245
+ const google = (window as unknown as { google?: { accounts: { id: { initialize: (config: { client_id: string; callback: (response: { credential: string }) => void }) => void; prompt: () => void } } } }).google;
246
+ if (!google) {
247
+ console.error("Google Sign-In not loaded");
248
+ return;
249
+ }
250
+
251
+ google.accounts.id.initialize({
252
+ client_id: googleClientId,
253
+ callback: async (response: { credential: string }) => {
254
+ try {
255
+ await authController.googleLogin(response.credential);
256
+ } catch (err: unknown) {
257
+ console.error("Google login error:", err);
258
+ }
259
+ }
260
+ });
261
+
262
+ google.accounts.id.prompt();
263
+ } catch (err: unknown) {
264
+ console.error("Google login error:", err);
265
+ }
266
+ };
267
+
268
+ return (
269
+ <div className="m-2 w-full">
270
+ <Button
271
+ disabled={disabled}
272
+ className={`w-full`}
273
+ onClick={handleGoogleLogin}>
274
+ <div className="flex items-center justify-center p-2 w-full h-8">
275
+ <div className="flex flex-col items-center justify-center w-8">
276
+ <svg viewBox="0 0 24 24" width="24" height="24">
277
+ <path fill="currentColor"
278
+ 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" />
279
+ <path fill="currentColor"
280
+ 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" />
281
+ <path fill="currentColor"
282
+ 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" />
283
+ <path fill="currentColor"
284
+ 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" />
285
+ </svg>
286
+ </div>
287
+ <div className="grow pl-2 text-center">
288
+ Continue with Google
289
+ </div>
290
+ </div>
291
+ </Button>
292
+ </div>
293
+ )
294
+ }
295
+
296
+ function LoginForm({
297
+ onClose,
298
+ onForgotPassword,
299
+ authController,
300
+ mode,
301
+ registrationMode,
302
+ noUserComponent,
303
+ disableSignupScreen,
304
+ bootstrapMode = false
305
+ }: {
306
+ onClose: () => void,
307
+ onForgotPassword: () => void,
308
+ authController: RebaseAuthController,
309
+ mode: "light" | "dark",
310
+ registrationMode: boolean,
311
+ noUserComponent?: ReactNode,
312
+ disableSignupScreen: boolean,
313
+ bootstrapMode?: boolean
314
+ }) {
315
+
316
+ const passwordRef = useRef<HTMLInputElement | null>(null);
317
+
318
+ const [email, setEmail] = useState<string>();
319
+ const [password, setPassword] = useState<string>();
320
+ const [displayName, setDisplayName] = useState<string>();
321
+
322
+ const loginMode = !registrationMode;
323
+
324
+ useEffect(() => {
325
+ if (!document) return;
326
+ const escFunction = (event: KeyboardEvent) => {
327
+ if (event.keyCode === 27) {
328
+ onClose();
329
+ }
330
+ };
331
+ document.addEventListener("keydown", escFunction, false);
332
+ return () => {
333
+ document.removeEventListener("keydown", escFunction, false);
334
+ };
335
+ }, [onClose]);
336
+
337
+ function handleEnterPassword() {
338
+ if (email && password) {
339
+ authController.emailPasswordLogin(email, password);
340
+ }
341
+ }
342
+
343
+ function handleRegistration() {
344
+ if (email && password) {
345
+ authController.register(email, password, displayName);
346
+ }
347
+ }
348
+
349
+ const onBackPressed = () => {
350
+ onClose();
351
+ }
352
+
353
+ const handleSubmit = (event: React.FormEvent) => {
354
+ event.preventDefault();
355
+ if (registrationMode)
356
+ handleRegistration();
357
+ else
358
+ handleEnterPassword();
359
+ }
360
+
361
+ const label = bootstrapMode
362
+ ? "Welcome! Create your admin account"
363
+ : registrationMode
364
+ ? "Create a new account"
365
+ : "Enter your email and password";
366
+
367
+ const button = registrationMode ? "Create account" : "Login";
368
+
369
+ return (
370
+ <form onSubmit={handleSubmit} className="flex flex-col items-center w-full max-w-[500px] gap-2">
371
+ {!bootstrapMode && (
372
+ <div className="w-full">
373
+ <IconButton onClick={onBackPressed}>
374
+ <ArrowBackIcon />
375
+ </IconButton>
376
+ </div>
377
+ )}
378
+
379
+ <div className="flex justify-center w-full py-2">
380
+ <Typography align={"center"} variant={bootstrapMode ? "subtitle1" : "subtitle2"}>{label}</Typography>
381
+ </div>
382
+
383
+ {bootstrapMode && (
384
+ <Typography variant="body2" className="text-gray-500 text-center mb-2">
385
+ No users found. Create the first account to get started. This account will have admin privileges.
386
+ </Typography>
387
+ )}
388
+
389
+ {registrationMode && (
390
+ <div className="w-full">
391
+ <TextField placeholder="Display Name (optional)"
392
+ className={"w-full"}
393
+ value={displayName ?? ""}
394
+ disabled={authController.initialLoading}
395
+ type="text"
396
+ onChange={(event) => setDisplayName(event.target.value)} />
397
+ </div>
398
+ )}
399
+
400
+ <div className="w-full">
401
+ <TextField placeholder="Email"
402
+ className={"w-full"}
403
+ autoFocus
404
+ value={email ?? ""}
405
+ disabled={authController.initialLoading}
406
+ type="email"
407
+ onChange={(event) => setEmail(event.target.value)} />
408
+ </div>
409
+
410
+ <div className="w-full">
411
+ {registrationMode && noUserComponent}
412
+ </div>
413
+
414
+ <div className="w-full">
415
+ <TextField placeholder="Password"
416
+ className={"w-full"}
417
+ value={password ?? ""}
418
+ disabled={authController.initialLoading}
419
+ inputRef={passwordRef}
420
+ type="password"
421
+ onChange={(event) => setPassword(event.target.value)} />
422
+ </div>
423
+
424
+ {registrationMode && (
425
+ <Typography variant="caption" className="text-gray-500 text-sm">
426
+ Password: 8+ chars, uppercase, lowercase, number
427
+ </Typography>
428
+ )}
429
+
430
+ {!registrationMode && (
431
+ <div className="w-full text-right">
432
+ <button
433
+ type="button"
434
+ className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
435
+ onClick={onForgotPassword}
436
+ >
437
+ Forgot password?
438
+ </button>
439
+ </div>
440
+ )}
441
+
442
+ <div className="flex justify-end items-center w-full gap-2">
443
+ {authController.authLoading && (
444
+ <CircularProgress />
445
+ )}
446
+ <Button type="submit" disabled={authController.authLoading}>
447
+ {button}
448
+ </Button>
449
+ </div>
450
+ </form>
451
+ );
452
+ }
453
+
454
+ function ForgotPasswordForm({
455
+ onClose,
456
+ authController
457
+ }: {
458
+ onClose: () => void,
459
+ authController: RebaseAuthController
460
+ }) {
461
+ const [email, setEmail] = useState<string>("");
462
+ const [submitted, setSubmitted] = useState(false);
463
+ const [error, setError] = useState<string | null>(null);
464
+
465
+ useEffect(() => {
466
+ if (!document) return;
467
+ const escFunction = (event: KeyboardEvent) => {
468
+ if (event.keyCode === 27) {
469
+ onClose();
470
+ }
471
+ };
472
+ document.addEventListener("keydown", escFunction, false);
473
+ return () => {
474
+ document.removeEventListener("keydown", escFunction, false);
475
+ };
476
+ }, [onClose]);
477
+
478
+ const handleSubmit = async (event: React.FormEvent) => {
479
+ event.preventDefault();
480
+ setError(null);
481
+
482
+ if (!email) {
483
+ setError("Please enter your email address");
484
+ return;
485
+ }
486
+
487
+ try {
488
+ await authController.forgotPassword(email);
489
+ setSubmitted(true);
490
+ } catch (err: unknown) {
491
+ // Check for EMAIL_NOT_CONFIGURED error
492
+ if (err instanceof Error && (err as { code?: string }).code === "EMAIL_NOT_CONFIGURED") {
493
+ setError("Password reset is not available. Please contact your administrator.");
494
+ } else {
495
+ // Still show success (security: don't reveal if email exists)
496
+ setSubmitted(true);
497
+ }
498
+ }
499
+ };
500
+
501
+ if (submitted) {
502
+ return (
503
+ <div className="flex flex-col items-center w-full max-w-[500px] gap-4 p-4">
504
+ <div className="w-full">
505
+ <IconButton onClick={onClose}>
506
+ <ArrowBackIcon />
507
+ </IconButton>
508
+ </div>
509
+
510
+ <div className="text-center">
511
+ <Typography variant="subtitle1" className="mb-4">
512
+ Check your email
513
+ </Typography>
514
+ <Typography variant="body2" className="text-gray-600">
515
+ If an account exists for <strong>{email}</strong>, you'll receive a password reset link shortly.
516
+ </Typography>
517
+ </div>
518
+
519
+ <Button onClick={onClose} className="mt-4">
520
+ Back to login
521
+ </Button>
522
+ </div>
523
+ );
524
+ }
525
+
526
+ return (
527
+ <form onSubmit={handleSubmit} className="flex flex-col items-center w-full max-w-[500px] gap-2">
528
+ <div className="w-full">
529
+ <IconButton onClick={onClose}>
530
+ <ArrowBackIcon />
531
+ </IconButton>
532
+ </div>
533
+
534
+ <div className="flex justify-center w-full py-2">
535
+ <Typography align={"center"} variant={"subtitle2"}>Reset your password</Typography>
536
+ </div>
537
+
538
+ <Typography variant="body2" className="text-gray-600 text-center mb-2">
539
+ Enter your email address and we'll send you a link to reset your password.
540
+ </Typography>
541
+
542
+ {error && (
543
+ <div className="w-full">
544
+ <ErrorView error={error} />
545
+ </div>
546
+ )}
547
+
548
+ <div className="w-full">
549
+ <TextField
550
+ placeholder="Email"
551
+ className={"w-full"}
552
+ autoFocus
553
+ value={email}
554
+ type="email"
555
+ onChange={(event) => setEmail(event.target.value)}
556
+ />
557
+ </div>
558
+
559
+ <div className="flex justify-end items-center w-full gap-2 mt-2">
560
+ {authController.authLoading && (
561
+ <CircularProgress />
562
+ )}
563
+ <Button type="submit" disabled={authController.authLoading || !email}>
564
+ Send reset link
565
+ </Button>
566
+ </div>
567
+ </form>
568
+ );
569
+ }
570
+