@rebasepro/auth 0.1.2 → 0.2.3

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.
@@ -1,724 +0,0 @@
1
-
2
- import React, { ReactNode, useEffect, useRef, useState } from "react";
3
-
4
- import { Button, CircularProgress, cls, IconButton, LoadingButton, Menu, MenuItem, TextField, Typography, iconSize } from "@rebasepro/ui";
5
- import { ArrowLeftIcon, MailIcon, MoonIcon, SunIcon, SunMoonIcon } from "lucide-react";
6
- import { ErrorView, LanguageToggle, RebaseLogo, useModeController, useTranslation } from "@rebasepro/core";
7
-
8
- import { RebaseAuthController } from "../types";
9
-
10
- /**
11
- * Props for RebaseLoginView
12
- */
13
- export interface RebaseLoginViewProps {
14
- /**
15
- * Auth controller from useRebaseAuthController
16
- */
17
- authController: RebaseAuthController;
18
-
19
- /**
20
- * Path to the logo displayed in the login screen
21
- */
22
- logo?: string;
23
-
24
- /**
25
- * Enable the skip login button
26
- */
27
- allowSkipLogin?: boolean;
28
-
29
- /**
30
- * Disable the login buttons
31
- */
32
- disabled?: boolean;
33
-
34
- /**
35
- * Prevent users from creating new accounts
36
- */
37
- disableSignupScreen?: boolean;
38
-
39
- /**
40
- * Display this component when no user is found
41
- */
42
- noUserComponent?: ReactNode;
43
-
44
- /**
45
- * Display this component below the sign-in buttons
46
- */
47
- additionalComponent?: ReactNode;
48
-
49
- /**
50
- * Error message when user is not allowed access
51
- */
52
- notAllowedError?: string | Error;
53
-
54
- /**
55
- * Enable Google login button (requires googleClientId in hook)
56
- */
57
- googleEnabled?: boolean;
58
-
59
- /**
60
- * Google client ID for OAuth
61
- */
62
- googleClientId?: string;
63
- }
64
-
65
- type AuthMode = "buttons" | "login" | "register" | "forgot";
66
-
67
- /**
68
- * Login view component for custom JWT authentication
69
- */
70
- export function RebaseLoginView({
71
- logo,
72
- authController,
73
- noUserComponent,
74
- disableSignupScreen = false,
75
- disabled = false,
76
- notAllowedError,
77
- googleEnabled = false,
78
- googleClientId
79
- }: RebaseLoginViewProps) {
80
-
81
- const modeState = useModeController();
82
- const { mode: colorMode, setMode: setColorMode } = modeState;
83
- const { t } = useTranslation();
84
-
85
- const [mode, setMode] = useState<AuthMode>("buttons");
86
- const [fadeIn, setFadeIn] = useState(false);
87
- const [viewVisible, setViewVisible] = useState(true);
88
-
89
- const switchMode = (newMode: AuthMode) => {
90
- setViewVisible(false);
91
- setTimeout(() => {
92
- setMode(newMode);
93
- setViewVisible(true);
94
- }, 150);
95
- };
96
-
97
- // Auto-show setup form when no users exist (bootstrap mode)
98
- const isBootstrapMode = authController.needsSetup;
99
-
100
- useEffect(() => {
101
- const timer = setTimeout(() => setFadeIn(true), 50);
102
- return () => clearTimeout(timer);
103
- }, []);
104
-
105
- function buildErrorView() {
106
- if (!authController.authProviderError) return null;
107
- if (authController.user != null) return null;
108
- return (
109
- <div className="w-full">
110
- <ErrorView error={authController.authProviderError.message ?? authController.authProviderError}/>
111
- </div>
112
- );
113
- }
114
-
115
- let logoComponent;
116
- if (logo) {
117
- logoComponent = <img src={logo}
118
- style={{
119
- height: "100%",
120
- width: "100%",
121
- objectFit: "cover"
122
- }}
123
- alt={"Logo"}/>;
124
- } else {
125
- logoComponent = <RebaseLogo/>;
126
- }
127
-
128
- let notAllowedMessage: string | undefined;
129
- if (notAllowedError) {
130
- if (typeof notAllowedError === "string") {
131
- notAllowedMessage = notAllowedError;
132
- } else if (notAllowedError instanceof Error) {
133
- notAllowedMessage = notAllowedError.message;
134
- } else {
135
- notAllowedMessage = "It looks like you don't have access to the CMS, based on the specified Authenticator configuration";
136
- }
137
- }
138
-
139
- const showRegistration = !disableSignupScreen && authController.registrationEnabled;
140
-
141
- return (
142
- <div
143
- className={cls(
144
- "relative flex items-center justify-center h-screen w-screen p-4 transition-opacity duration-500 bg-white dark:bg-surface-900",
145
- fadeIn ? "opacity-100" : "opacity-0"
146
- )}>
147
-
148
- {/* Top-right controls */}
149
- <div className="absolute top-4 right-4 flex items-center gap-1 z-10">
150
- <LanguageToggle/>
151
- <Menu
152
- trigger={<IconButton
153
- color="inherit"
154
- aria-label="Toggle theme">
155
- {colorMode === "dark"
156
- ? <MoonIcon size={iconSize.small}/>
157
- : <SunIcon size={iconSize.small}/>}
158
- </IconButton>}>
159
- <MenuItem onClick={() => setColorMode("dark")}><MoonIcon size={iconSize.smallest}/> {t("dark_mode")}</MenuItem>
160
- <MenuItem onClick={() => setColorMode("light")}><SunIcon size={iconSize.smallest}/> {t("light_mode")}</MenuItem>
161
- <MenuItem onClick={() => setColorMode("system")}><SunMoonIcon size={iconSize.smallest}/> {t("system_mode")}</MenuItem>
162
- </Menu>
163
- </div>
164
-
165
- <div className="flex flex-col items-center w-[480px] max-w-full p-8 sm:p-10">
166
- {/* Logo */}
167
- <div className="w-32 h-32 m-2 mb-6">
168
- {logoComponent}
169
- </div>
170
-
171
- {notAllowedMessage && (
172
- <div className="p-4 w-full">
173
- <ErrorView error={notAllowedMessage}/>
174
- </div>
175
- )}
176
-
177
- {mode !== "forgot" && buildErrorView()}
178
-
179
- <div className={cls(
180
- "w-full transition-opacity duration-150",
181
- viewVisible ? "opacity-100" : "opacity-0"
182
- )}>
183
- {/* Bootstrap mode: show setup form directly */}
184
- {isBootstrapMode && !authController.user && (
185
- <LoginForm
186
- authController={authController}
187
- registrationMode={true}
188
- onClose={() => {}}
189
- onForgotPassword={() => {}}
190
- noUserComponent={noUserComponent}
191
- disableSignupScreen={false}
192
- bootstrapMode={true}
193
- />
194
- )}
195
-
196
- {/* Normal mode */}
197
- {!isBootstrapMode && (
198
- <>
199
- {/* Provider buttons screen */}
200
- {mode === "buttons" && (
201
- <div className="w-full flex flex-col gap-3 mt-2">
202
- <LoginButton
203
- disabled={disabled}
204
- text={"Sign in with email"}
205
- icon={<MailIcon/>}
206
- onClick={() => switchMode("login")}
207
- />
208
- {googleEnabled && googleClientId && (
209
- <GoogleLoginButton
210
- disabled={disabled}
211
- googleClientId={googleClientId}
212
- authController={authController}
213
- />
214
- )}
215
- {showRegistration && (
216
- <div className="mt-2 text-center">
217
- <Typography variant="body2" color="secondary">
218
- Don&apos;t have an account?{" "}
219
- <button
220
- type="button"
221
- className={cls(
222
- "font-semibold hover:underline cursor-pointer",
223
- "text-primary-600 dark:text-primary-400"
224
- )}
225
- onClick={() => switchMode("register")}
226
- >
227
- Create one
228
- </button>
229
- </Typography>
230
- </div>
231
- )}
232
- </div>
233
- )}
234
-
235
- {/* Login form */}
236
- {mode === "login" && (
237
- <LoginForm
238
- authController={authController}
239
- registrationMode={false}
240
- onClose={() => switchMode("buttons")}
241
- onForgotPassword={() => switchMode("forgot")}
242
- noUserComponent={noUserComponent}
243
- disableSignupScreen={disableSignupScreen}
244
- switchToRegister={showRegistration ? () => switchMode("register") : undefined}
245
- />
246
- )}
247
-
248
- {/* Registration form */}
249
- {mode === "register" && (
250
- <LoginForm
251
- authController={authController}
252
- registrationMode={true}
253
- onClose={() => switchMode("buttons")}
254
- onForgotPassword={() => switchMode("forgot")}
255
- noUserComponent={noUserComponent}
256
- disableSignupScreen={disableSignupScreen}
257
- switchToLogin={() => switchMode("login")}
258
- />
259
- )}
260
-
261
- {/* Forgot password form */}
262
- {mode === "forgot" && (
263
- <ForgotPasswordForm
264
- authController={authController}
265
- onClose={() => switchMode("login")}
266
- />
267
- )}
268
- </>
269
- )}
270
- </div>
271
- </div>
272
- </div>
273
- );
274
- }
275
-
276
- function LoginButton({
277
- icon,
278
- onClick,
279
- text,
280
- disabled
281
- }: { icon: React.ReactNode, onClick: () => void, text: string, disabled?: boolean }) {
282
- return (
283
- <Button
284
- disabled={disabled}
285
- className="w-full"
286
- variant="outlined"
287
- size="large"
288
- onClick={onClick}>
289
- <div className="flex items-center justify-center w-full gap-3 py-1">
290
- <span className="flex items-center justify-center w-5 h-5">
291
- {icon}
292
- </span>
293
- <Typography variant="button">{text}</Typography>
294
- </div>
295
- </Button>
296
- );
297
- }
298
-
299
- const GoogleIcon = () => (
300
- <svg viewBox="0 0 24 24" width="20" height="20">
301
- <path fill="#4285F4"
302
- 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"/>
303
- <path fill="#34A853"
304
- 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"/>
305
- <path fill="#FBBC05"
306
- 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"/>
307
- <path fill="#EA4335"
308
- 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"/>
309
- </svg>
310
- );
311
-
312
- /** Google Identity Services SDK — injected by the GIS <script> tag. */
313
- declare global {
314
- interface Window {
315
- google?: {
316
- accounts: {
317
- oauth2: {
318
- initCodeClient(config: {
319
- client_id: string;
320
- scope: string;
321
- ux_mode: "popup" | "redirect";
322
- callback: (response: { code?: string; error?: string }) => void;
323
- }): { requestCode(): void };
324
- };
325
- };
326
- };
327
- }
328
- }
329
-
330
- function GoogleLoginButton({
331
- disabled,
332
- googleClientId,
333
- authController
334
- }: {
335
- disabled?: boolean,
336
- googleClientId: string,
337
- authController: RebaseAuthController
338
- }) {
339
- const codeClientRef = useRef<{ requestCode(): void } | null>(null);
340
-
341
- useEffect(() => {
342
- const google = window.google;
343
- if (!google || codeClientRef.current) return;
344
-
345
- codeClientRef.current = google.accounts.oauth2.initCodeClient({
346
- client_id: googleClientId,
347
- scope: "openid email profile",
348
- ux_mode: "popup",
349
- callback: async (response: { code?: string; error?: string }) => {
350
- if (response.error || !response.code) {
351
- console.error("Google login error:", response.error);
352
- return;
353
- }
354
- try {
355
- // Send the authorization code to the backend.
356
- // redirectUri "postmessage" is required when using popup ux_mode.
357
- await authController.googleLogin({
358
- code: response.code,
359
- redirectUri: "postmessage"
360
- });
361
- } catch (err: unknown) {
362
- console.error("Google login error:", err);
363
- }
364
- }
365
- });
366
- }, [googleClientId, authController]);
367
-
368
- const handleClick = () => {
369
- if (!codeClientRef.current) {
370
- console.error("Google Sign-In not loaded");
371
- return;
372
- }
373
- codeClientRef.current.requestCode();
374
- };
375
-
376
- return (
377
- <LoginButton
378
- disabled={disabled}
379
- text="Sign in with Google"
380
- icon={<GoogleIcon/>}
381
- onClick={handleClick}
382
- />
383
- );
384
- }
385
-
386
- function LoginForm({
387
- onClose,
388
- onForgotPassword,
389
- authController,
390
- registrationMode,
391
- noUserComponent,
392
- disableSignupScreen,
393
- bootstrapMode = false,
394
- switchToRegister,
395
- switchToLogin
396
- }: {
397
- onClose: () => void,
398
- onForgotPassword: () => void,
399
- authController: RebaseAuthController,
400
- registrationMode: boolean,
401
- noUserComponent?: ReactNode,
402
- disableSignupScreen: boolean,
403
- bootstrapMode?: boolean,
404
- switchToRegister?: () => void,
405
- switchToLogin?: () => void
406
- }) {
407
- const passwordRef = useRef<HTMLInputElement | null>(null);
408
-
409
- const [email, setEmail] = useState<string>();
410
- const [password, setPassword] = useState<string>();
411
- const [displayName, setDisplayName] = useState<string>();
412
-
413
- useEffect(() => {
414
- if (!document) return;
415
- const escFunction = (event: KeyboardEvent) => {
416
- if (event.keyCode === 27) {
417
- onClose();
418
- }
419
- };
420
- document.addEventListener("keydown", escFunction, false);
421
- return () => {
422
- document.removeEventListener("keydown", escFunction, false);
423
- };
424
- }, [onClose]);
425
-
426
- function handleEnterPassword() {
427
- if (email && password) {
428
- authController.emailPasswordLogin(email, password);
429
- }
430
- }
431
-
432
- function handleRegistration() {
433
- if (email && password) {
434
- authController.register(email, password, displayName);
435
- }
436
- }
437
-
438
- const handleSubmit = (event: React.FormEvent) => {
439
- event.preventDefault();
440
- if (registrationMode)
441
- handleRegistration();
442
- else
443
- handleEnterPassword();
444
- };
445
-
446
- const title = bootstrapMode
447
- ? "Welcome!"
448
- : registrationMode
449
- ? "Create account"
450
- : "Sign in";
451
-
452
- const subtitle = bootstrapMode
453
- ? "Create your admin account to get started. This account will have admin privileges."
454
- : registrationMode
455
- ? "Fill in your details to create a new account"
456
- : "Enter your credentials to continue";
457
-
458
- const buttonLabel = registrationMode ? "Create account" : "Sign in";
459
-
460
- return (
461
- <form onSubmit={handleSubmit} className="flex flex-col w-full gap-1 mt-2">
462
- {!bootstrapMode && (
463
- <div className="w-full mb-2 -ml-2.5">
464
- <IconButton onClick={onClose}>
465
- <ArrowLeftIcon/>
466
- </IconButton>
467
- </div>
468
- )}
469
-
470
- <Typography variant="h6" className="mb-0.5">
471
- {title}
472
- </Typography>
473
- <Typography variant="body2" color="secondary" className="mb-5">
474
- {subtitle}
475
- </Typography>
476
-
477
- {registrationMode && noUserComponent && (
478
- <div className="w-full mb-2">
479
- {noUserComponent}
480
- </div>
481
- )}
482
-
483
- {registrationMode && (
484
- <div className="w-full mb-3">
485
- <Typography variant="label" color="secondary" className="mb-1">
486
- Display Name
487
- </Typography>
488
- <TextField placeholder="Jane Doe (optional)"
489
- className="w-full"
490
- value={displayName ?? ""}
491
- disabled={authController.initialLoading}
492
- type="text"
493
- size="medium"
494
- onChange={(event) => setDisplayName(event.target.value)}/>
495
- </div>
496
- )}
497
-
498
- <div className="w-full mb-3">
499
- <Typography variant="label" color="secondary" className="mb-1">
500
- Email
501
- </Typography>
502
- <TextField placeholder="you@example.com"
503
- className="w-full"
504
- autoFocus
505
- value={email ?? ""}
506
- disabled={authController.initialLoading}
507
- type="email"
508
- size="medium"
509
- onChange={(event) => setEmail(event.target.value)}/>
510
- </div>
511
-
512
- <div className="w-full mb-1">
513
- <Typography variant="label" color="secondary" className="mb-1">
514
- Password
515
- </Typography>
516
- <TextField placeholder="••••••••"
517
- className="w-full"
518
- value={password ?? ""}
519
- disabled={authController.initialLoading}
520
- inputRef={passwordRef}
521
- type="password"
522
- size="medium"
523
- onChange={(event) => setPassword(event.target.value)}/>
524
- </div>
525
-
526
- {registrationMode && (
527
- <Typography variant="caption" color="secondary" className="mb-3">
528
- Password must be 8+ characters with uppercase, lowercase, and a number
529
- </Typography>
530
- )}
531
-
532
- {!registrationMode && (
533
- <div className="w-full text-right mb-3">
534
- <button
535
- type="button"
536
- className={cls(
537
- "text-xs font-medium hover:underline cursor-pointer",
538
- "text-primary-600 dark:text-primary-400"
539
- )}
540
- onClick={onForgotPassword}
541
- >
542
- Forgot password?
543
- </button>
544
- </div>
545
- )}
546
-
547
- <LoadingButton
548
- type="submit"
549
- variant="filled"
550
- color="primary"
551
- className="w-full mt-1"
552
- size="large"
553
- loading={authController.authLoading}
554
- disabled={authController.authLoading || !email || !password}
555
- >
556
- {buttonLabel}
557
- </LoadingButton>
558
-
559
- {/* Switch between login/register */}
560
- {switchToRegister && (
561
- <div className="mt-4 text-center">
562
- <Typography variant="body2" color="secondary">
563
- Don&apos;t have an account?{" "}
564
- <button
565
- type="button"
566
- className={cls(
567
- "font-semibold hover:underline cursor-pointer",
568
- "text-primary-600 dark:text-primary-400"
569
- )}
570
- onClick={switchToRegister}
571
- >
572
- Create one
573
- </button>
574
- </Typography>
575
- </div>
576
- )}
577
-
578
- {switchToLogin && (
579
- <div className="mt-4 text-center">
580
- <Typography variant="body2" color="secondary">
581
- Already have an account?{" "}
582
- <button
583
- type="button"
584
- className={cls(
585
- "font-semibold hover:underline cursor-pointer",
586
- "text-primary-600 dark:text-primary-400"
587
- )}
588
- onClick={switchToLogin}
589
- >
590
- Sign in
591
- </button>
592
- </Typography>
593
- </div>
594
- )}
595
- </form>
596
- );
597
- }
598
-
599
- function ForgotPasswordForm({
600
- onClose,
601
- authController
602
- }: {
603
- onClose: () => void,
604
- authController: RebaseAuthController
605
- }) {
606
- const [email, setEmail] = useState<string>("");
607
- const [submitted, setSubmitted] = useState(false);
608
- const [error, setError] = useState<string | null>(null);
609
-
610
- useEffect(() => {
611
- if (!document) return;
612
- const escFunction = (event: KeyboardEvent) => {
613
- if (event.keyCode === 27) {
614
- onClose();
615
- }
616
- };
617
- document.addEventListener("keydown", escFunction, false);
618
- return () => {
619
- document.removeEventListener("keydown", escFunction, false);
620
- };
621
- }, [onClose]);
622
-
623
- const handleSubmit = async (event: React.FormEvent) => {
624
- event.preventDefault();
625
- setError(null);
626
-
627
- if (!email) {
628
- setError("Please enter your email address");
629
- return;
630
- }
631
-
632
- try {
633
- await authController.forgotPassword(email);
634
- setSubmitted(true);
635
- } catch (err: unknown) {
636
- // Check for EMAIL_NOT_CONFIGURED error
637
- if (err instanceof Error && (err as { code?: string }).code === "EMAIL_NOT_CONFIGURED") {
638
- setError("Password reset is not available. Please contact your administrator.");
639
- } else {
640
- // Still show success (security: don't reveal if email exists)
641
- setSubmitted(true);
642
- }
643
- }
644
- };
645
-
646
- if (submitted) {
647
- return (
648
- <div className="flex flex-col w-full gap-4 mt-2">
649
- <div className="w-full -ml-2.5">
650
- <IconButton onClick={onClose}>
651
- <ArrowLeftIcon/>
652
- </IconButton>
653
- </div>
654
-
655
- <div className={cls(
656
- "text-center rounded-xl p-6",
657
- "bg-surface-50 dark:bg-surface-950"
658
- )}>
659
- <div className="text-3xl mb-3">📧</div>
660
- <Typography variant="subtitle1" className="mb-2">
661
- Check your email
662
- </Typography>
663
- <Typography variant="body2" color="secondary">
664
- If an account exists for <strong>{email}</strong>, you&apos;ll receive a password reset link shortly.
665
- </Typography>
666
- </div>
667
-
668
- <Button onClick={onClose} variant="text" className="mt-2">
669
- Back to sign in
670
- </Button>
671
- </div>
672
- );
673
- }
674
-
675
- return (
676
- <form onSubmit={handleSubmit} className="flex flex-col w-full gap-1 mt-2">
677
- <div className="w-full mb-2 -ml-2.5">
678
- <IconButton onClick={onClose}>
679
- <ArrowLeftIcon/>
680
- </IconButton>
681
- </div>
682
-
683
- <Typography variant="h6" className="mb-0.5">
684
- Reset password
685
- </Typography>
686
- <Typography variant="body2" color="secondary" className="mb-5">
687
- Enter your email and we&apos;ll send you a reset link.
688
- </Typography>
689
-
690
- {error && (
691
- <div className="w-full mb-3">
692
- <ErrorView error={error}/>
693
- </div>
694
- )}
695
-
696
- <div className="w-full mb-3">
697
- <Typography variant="label" color="secondary" className="mb-1">
698
- Email
699
- </Typography>
700
- <TextField
701
- placeholder="you@example.com"
702
- className="w-full"
703
- autoFocus
704
- value={email}
705
- type="email"
706
- size="medium"
707
- onChange={(event) => setEmail(event.target.value)}
708
- />
709
- </div>
710
-
711
- <LoadingButton
712
- type="submit"
713
- variant="filled"
714
- color="primary"
715
- className="w-full"
716
- size="large"
717
- loading={authController.authLoading}
718
- disabled={authController.authLoading || !email}
719
- >
720
- Send reset link
721
- </LoadingButton>
722
- </form>
723
- );
724
- }