@delmaredigital/payload-better-auth 0.7.3 → 0.7.5

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.
@@ -52,6 +52,28 @@ export type LoginViewProps = {
52
52
  * The reset token will be appended as ?token=xxx
53
53
  */
54
54
  resetPasswordUrl?: string;
55
+ /**
56
+ * Enable email + password sign-in.
57
+ * - true: Always show the password field
58
+ * - false: Hide the password field (passwordless-only)
59
+ * - 'auto' (default): Auto-detect via the /sign-in/email endpoint
60
+ */
61
+ enablePassword?: boolean | 'auto';
62
+ /**
63
+ * Enable magic-link sign-in ("email me a link").
64
+ * - true / false / 'auto' (default: auto-detect via /sign-in/magic-link)
65
+ */
66
+ enableMagicLink?: boolean | 'auto';
67
+ /**
68
+ * Enable email-OTP sign-in ("email me a code").
69
+ * - true / false / 'auto' (default: auto-detect via /email-otp/send-verification-otp)
70
+ */
71
+ enableEmailOtp?: boolean | 'auto';
72
+ /**
73
+ * Where the emailed magic link returns after verification.
74
+ * Default: afterLoginPath
75
+ */
76
+ magicLinkCallbackURL?: string;
55
77
  };
56
- export declare function LoginView({ authClient: providedClient, logo, title, afterLoginPath, requiredRole, requireAllRoles, enablePasskey, enableSignUp, defaultSignUpRole, enableForgotPassword, resetPasswordUrl, }: LoginViewProps): import("react").JSX.Element;
78
+ export declare function LoginView({ authClient: providedClient, logo, title, afterLoginPath, requiredRole, requireAllRoles, enablePasskey, enableSignUp, defaultSignUpRole, enableForgotPassword, resetPasswordUrl, enablePassword, enableMagicLink, enableEmailOtp, magicLinkCallbackURL, }: LoginViewProps): import("react").JSX.Element;
57
79
  export default LoginView;
@@ -3,8 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useState, useEffect, useRef } from 'react';
4
4
  import { useRouter } from 'next/navigation.js';
5
5
  import { createAuthClient } from 'better-auth/react';
6
- import { twoFactorClient } from 'better-auth/client/plugins';
6
+ import { twoFactorClient, magicLinkClient, emailOTPClient } from 'better-auth/client/plugins';
7
7
  import { hasAnyRole, hasAllRoles } from '../utils/access.js';
8
+ import { resolveAvailability, pickPrimaryMethod } from '../utils/loginMethods.js';
8
9
  import { useConfig } from '@payloadcms/ui';
9
10
  /**
10
11
  * Check if user has the required role(s)
@@ -21,7 +22,7 @@ import { useConfig } from '@payloadcms/ui';
21
22
  }
22
23
  return hasAnyRole(user, roles);
23
24
  }
24
- export function LoginView({ authClient: providedClient, logo, title = 'Login', afterLoginPath = '/admin', requiredRole = 'admin', requireAllRoles = false, enablePasskey = 'auto', enableSignUp = 'auto', defaultSignUpRole = 'user', enableForgotPassword = 'auto', resetPasswordUrl }) {
25
+ export function LoginView({ authClient: providedClient, logo, title = 'Login', afterLoginPath = '/admin', requiredRole = 'admin', requireAllRoles = false, enablePasskey = 'auto', enableSignUp = 'auto', defaultSignUpRole = 'user', enableForgotPassword = 'auto', resetPasswordUrl, enablePassword = 'auto', enableMagicLink = 'auto', enableEmailOtp = 'auto', magicLinkCallbackURL }) {
25
26
  const router = useRouter();
26
27
  // Payload Config
27
28
  const { config: { routes: { admin: adminRoute, api: apiRoute } } } = useConfig();
@@ -43,6 +44,15 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
43
44
  const [passkeyAvailable, setPasskeyAvailable] = useState(enablePasskey === true);
44
45
  const [signUpAvailable, setSignUpAvailable] = useState(enableSignUp === true);
45
46
  const [forgotPasswordAvailable, setForgotPasswordAvailable] = useState(enableForgotPassword === true);
47
+ // Probe results for the new methods (null = not yet probed).
48
+ // Password is optimistic (shown until a 404 proves the strategy is disabled);
49
+ // magic-link and email-OTP stay hidden until a probe confirms availability.
50
+ const [passwordProbe, setPasswordProbe] = useState(true);
51
+ const [magicLinkProbe, setMagicLinkProbe] = useState(null);
52
+ const [emailOtpProbe, setEmailOtpProbe] = useState(null);
53
+ // Email-OTP code entry state
54
+ const [otp, setOtp] = useState('');
55
+ const [otpLoading, setOtpLoading] = useState(false);
46
56
  // Two-factor authentication state
47
57
  const [totpCode, setTotpCode] = useState('');
48
58
  const [totpLoading, setTotpLoading] = useState(false);
@@ -55,6 +65,8 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
55
65
  clientRef.current = createAuthClient({
56
66
  plugins: [
57
67
  twoFactorClient(),
68
+ magicLinkClient(),
69
+ emailOTPClient(),
58
70
  passkeyClient()
59
71
  ]
60
72
  });
@@ -150,6 +162,54 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
150
162
  }, [
151
163
  enableForgotPassword
152
164
  ]);
165
+ // Auto-detect password (email) sign-in availability if set to 'auto'
166
+ useEffect(()=>{
167
+ if (enablePassword !== 'auto') return;
168
+ fetch(`${apiRoute}/auth/sign-in/email`, {
169
+ method: 'OPTIONS',
170
+ credentials: 'include'
171
+ }).then((res)=>setPasswordProbe(res.status !== 404)).catch(()=>setPasswordProbe(true)); // core method: assume available on probe error
172
+ }, [
173
+ enablePassword
174
+ ]);
175
+ // Auto-detect magic-link availability if set to 'auto'
176
+ useEffect(()=>{
177
+ if (enableMagicLink !== 'auto') return;
178
+ fetch(`${apiRoute}/auth/sign-in/magic-link`, {
179
+ method: 'OPTIONS',
180
+ credentials: 'include'
181
+ }).then((res)=>setMagicLinkProbe(res.status !== 404)).catch(()=>setMagicLinkProbe(false)); // optional method: assume unavailable on error
182
+ }, [
183
+ enableMagicLink
184
+ ]);
185
+ // Auto-detect email-OTP availability if set to 'auto'
186
+ useEffect(()=>{
187
+ if (enableEmailOtp !== 'auto') return;
188
+ fetch(`${apiRoute}/auth/email-otp/send-verification-otp`, {
189
+ method: 'OPTIONS',
190
+ credentials: 'include'
191
+ }).then((res)=>setEmailOtpProbe(res.status !== 404)).catch(()=>setEmailOtpProbe(false));
192
+ }, [
193
+ enableEmailOtp
194
+ ]);
195
+ /**
196
+ * Shared post-authentication tail: re-fetch the session for complete user data
197
+ * (e.g. roles applied by hooks), enforce the role gate, and redirect on success.
198
+ * Returns the outcome so each caller can reset its own loading flag / show its own
199
+ * "no session" message.
200
+ */ async function completeSignIn(// eslint-disable-next-line @typescript-eslint/no-explicit-any
201
+ client) {
202
+ const sessionResult = await client.getSession();
203
+ if (!sessionResult.data?.user) return 'noSession';
204
+ const user = sessionResult.data.user;
205
+ if (!checkUserRoles(user, requiredRole, requireAllRoles)) {
206
+ setAccessDenied(true);
207
+ return 'accessDenied';
208
+ }
209
+ router.push(afterLoginPath);
210
+ router.refresh();
211
+ return 'redirected';
212
+ }
153
213
  async function handleSubmit(e) {
154
214
  e.preventDefault();
155
215
  setLoading(true);
@@ -174,15 +234,13 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
174
234
  return;
175
235
  }
176
236
  if (result.data?.user) {
177
- const user = result.data.user;
178
- // Check role if required
179
- if (!checkUserRoles(user, requiredRole, requireAllRoles)) {
180
- setAccessDenied(true);
237
+ const outcome = await completeSignIn(client);
238
+ if (outcome === 'noSession') {
239
+ setError('Sign-in succeeded but session could not be verified');
240
+ setLoading(false);
241
+ } else if (outcome === 'accessDenied') {
181
242
  setLoading(false);
182
- return;
183
243
  }
184
- router.push(afterLoginPath);
185
- router.refresh();
186
244
  }
187
245
  } catch {
188
246
  setError('An error occurred. Please try again.');
@@ -221,20 +279,15 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
221
279
  }
222
280
  // Registration successful - either auto-signed in or need to verify email
223
281
  if (result.data?.user) {
224
- // Re-fetch session to get updated user data (role may have been changed by hooks)
225
- // This handles cases like firstUserAdmin where the role is set after creation
226
- const sessionResult = await client.getSession();
227
- if (sessionResult.data?.user) {
228
- const user = sessionResult.data.user;
229
- // Check role if required
230
- if (!checkUserRoles(user, requiredRole, requireAllRoles)) {
231
- setAccessDenied(true);
232
- setLoading(false);
233
- return;
234
- }
282
+ // Re-fetch session via completeSignIn to pick up hook-applied roles
283
+ // (e.g. firstUserAdmin sets the role after creation).
284
+ const outcome = await completeSignIn(client);
285
+ if (outcome === 'noSession') {
286
+ setError('Account created but session could not be verified. Please sign in.');
287
+ setLoading(false);
288
+ } else if (outcome === 'accessDenied') {
289
+ setLoading(false);
235
290
  }
236
- router.push(afterLoginPath);
237
- router.refresh();
238
291
  } else {
239
292
  // Likely requires email verification - show success and switch to login
240
293
  setSuccessMessage('Account created! Please check your email to verify your account.');
@@ -286,21 +339,15 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
286
339
  setTotpLoading(false);
287
340
  return;
288
341
  }
289
- // Verify-totp may not return all user fields (like custom 'role')
290
- // Fetch the session to get complete user data for role check
291
- if (requiredRole) {
292
- const sessionResult = await client.getSession();
293
- if (sessionResult.data?.user) {
294
- const user = sessionResult.data.user;
295
- if (!checkUserRoles(user, requiredRole, requireAllRoles)) {
296
- setAccessDenied(true);
297
- setTotpLoading(false);
298
- return;
299
- }
300
- }
342
+ // verify-totp may not return all user fields (e.g. custom 'role');
343
+ // completeSignIn re-fetches the session for the role gate.
344
+ const outcome = await completeSignIn(client);
345
+ if (outcome === 'noSession') {
346
+ setError('Sign-in succeeded but session could not be verified');
347
+ setTotpLoading(false);
348
+ } else if (outcome === 'accessDenied') {
349
+ setTotpLoading(false);
301
350
  }
302
- router.push(afterLoginPath);
303
- router.refresh();
304
351
  } catch {
305
352
  setError('An error occurred. Please try again.');
306
353
  setTotpLoading(false);
@@ -314,6 +361,7 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
314
361
  if (newView === 'login') {
315
362
  setTotpCode('');
316
363
  setConfirmPassword('');
364
+ setOtp('');
317
365
  } else if (newView === 'register') {
318
366
  setPassword('');
319
367
  setConfirmPassword('');
@@ -337,23 +385,14 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
337
385
  setPasskeyLoading(false);
338
386
  return;
339
387
  }
340
- // Passkey sign-in succeeded - fetch session to get full user data (including role)
341
- // This is more reliable than checking result.data.user which may vary by SDK version
342
- const sessionResult = await client.getSession();
343
- if (sessionResult.data?.user) {
344
- const user = sessionResult.data.user;
345
- // Check role if required
346
- if (!checkUserRoles(user, requiredRole, requireAllRoles)) {
347
- setAccessDenied(true);
348
- setPasskeyLoading(false);
349
- return;
350
- }
351
- router.push(afterLoginPath);
352
- router.refresh();
353
- } else {
354
- // Session fetch failed - shouldn't happen after successful passkey auth
388
+ // Passkey sign-in succeeded - completeSignIn re-fetches the session for full
389
+ // user data (including role), more reliable than result.data.user across SDK versions.
390
+ const outcome = await completeSignIn(client);
391
+ if (outcome === 'noSession') {
355
392
  setError('Authentication succeeded but session could not be verified');
356
393
  setPasskeyLoading(false);
394
+ } else if (outcome === 'accessDenied') {
395
+ setPasskeyLoading(false);
357
396
  }
358
397
  } catch (err) {
359
398
  if (err instanceof Error && err.name === 'NotAllowedError') {
@@ -364,6 +403,88 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
364
403
  setPasskeyLoading(false);
365
404
  }
366
405
  }
406
+ async function handleSendMagicLink(e) {
407
+ e?.preventDefault();
408
+ if (!email) {
409
+ setError('Please enter your email address first');
410
+ return;
411
+ }
412
+ setLoading(true);
413
+ setError(null);
414
+ setSuccessMessage(null);
415
+ try {
416
+ const client = await getClient();
417
+ const result = await client.signIn.magicLink({
418
+ email,
419
+ callbackURL: magicLinkCallbackURL ?? afterLoginPath
420
+ });
421
+ if (result.error) {
422
+ setError(result.error.message ?? 'Failed to send sign-in link');
423
+ setLoading(false);
424
+ return;
425
+ }
426
+ setViewMode('magicLinkSent');
427
+ setLoading(false);
428
+ } catch {
429
+ setError('An error occurred. Please try again.');
430
+ setLoading(false);
431
+ }
432
+ }
433
+ async function handleSendEmailOtp(e) {
434
+ e?.preventDefault();
435
+ if (!email) {
436
+ setError('Please enter your email address first');
437
+ return;
438
+ }
439
+ setLoading(true);
440
+ setError(null);
441
+ setSuccessMessage(null);
442
+ try {
443
+ const client = await getClient();
444
+ const result = await client.emailOtp.sendVerificationOtp({
445
+ email,
446
+ type: 'sign-in'
447
+ });
448
+ if (result.error) {
449
+ setError(result.error.message ?? 'Failed to send verification code');
450
+ setLoading(false);
451
+ return;
452
+ }
453
+ setOtp('');
454
+ setViewMode('emailOtp');
455
+ setLoading(false);
456
+ } catch {
457
+ setError('An error occurred. Please try again.');
458
+ setLoading(false);
459
+ }
460
+ }
461
+ async function handleVerifyEmailOtp(e) {
462
+ e.preventDefault();
463
+ setOtpLoading(true);
464
+ setError(null);
465
+ try {
466
+ const client = await getClient();
467
+ const result = await client.signIn.emailOtp({
468
+ email,
469
+ otp
470
+ });
471
+ if (result.error) {
472
+ setError(result.error.message ?? 'Invalid verification code');
473
+ setOtpLoading(false);
474
+ return;
475
+ }
476
+ const outcome = await completeSignIn(client);
477
+ if (outcome === 'noSession') {
478
+ setError('Sign-in succeeded but session could not be verified');
479
+ setOtpLoading(false);
480
+ } else if (outcome === 'accessDenied') {
481
+ setOtpLoading(false);
482
+ }
483
+ } catch {
484
+ setError('An error occurred. Please try again.');
485
+ setOtpLoading(false);
486
+ }
487
+ }
367
488
  async function handleSignOut() {
368
489
  const client = await getClient();
369
490
  await client.signOut();
@@ -592,6 +713,156 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
592
713
  })
593
714
  });
594
715
  }
716
+ // Email-OTP code entry view
717
+ if (viewMode === 'emailOtp') {
718
+ return /*#__PURE__*/ _jsx("div", {
719
+ style: {
720
+ minHeight: '100vh',
721
+ display: 'flex',
722
+ alignItems: 'center',
723
+ justifyContent: 'center',
724
+ background: 'var(--theme-bg)',
725
+ padding: 'var(--base)'
726
+ },
727
+ children: /*#__PURE__*/ _jsxs("div", {
728
+ style: {
729
+ background: 'var(--theme-elevation-50)',
730
+ padding: 'calc(var(--base) * 2)',
731
+ borderRadius: 'var(--style-radius-m)',
732
+ boxShadow: '0 2px 20px rgba(0, 0, 0, 0.1)',
733
+ width: '100%',
734
+ maxWidth: '400px'
735
+ },
736
+ children: [
737
+ logo && /*#__PURE__*/ _jsx("div", {
738
+ style: {
739
+ textAlign: 'center',
740
+ marginBottom: 'calc(var(--base) * 1.5)'
741
+ },
742
+ children: logo
743
+ }),
744
+ /*#__PURE__*/ _jsx("h1", {
745
+ style: {
746
+ color: 'var(--theme-text)',
747
+ fontSize: 'var(--font-size-h3)',
748
+ fontWeight: 600,
749
+ margin: '0 0 calc(var(--base) * 0.5) 0',
750
+ textAlign: 'center'
751
+ },
752
+ children: "Enter Your Code"
753
+ }),
754
+ /*#__PURE__*/ _jsxs("p", {
755
+ style: {
756
+ color: 'var(--theme-text)',
757
+ opacity: 0.7,
758
+ fontSize: 'var(--font-size-small)',
759
+ textAlign: 'center',
760
+ marginBottom: 'calc(var(--base) * 1.5)'
761
+ },
762
+ children: [
763
+ "We've sent a verification code to ",
764
+ /*#__PURE__*/ _jsx("strong", {
765
+ children: email
766
+ })
767
+ ]
768
+ }),
769
+ /*#__PURE__*/ _jsxs("form", {
770
+ onSubmit: handleVerifyEmailOtp,
771
+ children: [
772
+ /*#__PURE__*/ _jsxs("div", {
773
+ style: {
774
+ marginBottom: 'calc(var(--base) * 1.5)'
775
+ },
776
+ children: [
777
+ /*#__PURE__*/ _jsx("label", {
778
+ htmlFor: "email-otp-code",
779
+ style: {
780
+ display: 'block',
781
+ color: 'var(--theme-text)',
782
+ marginBottom: 'calc(var(--base) * 0.5)',
783
+ fontSize: 'var(--font-size-small)',
784
+ fontWeight: 500
785
+ },
786
+ children: "Verification Code"
787
+ }),
788
+ /*#__PURE__*/ _jsx("input", {
789
+ id: "email-otp-code",
790
+ type: "text",
791
+ inputMode: "numeric",
792
+ autoComplete: "one-time-code",
793
+ value: otp,
794
+ onChange: (e)=>setOtp(e.target.value.replace(/\D/g, '').slice(0, 6)),
795
+ required: true,
796
+ placeholder: "000000",
797
+ style: {
798
+ width: '100%',
799
+ padding: 'calc(var(--base) * 0.75)',
800
+ background: 'var(--theme-input-bg)',
801
+ border: '1px solid var(--theme-elevation-150)',
802
+ borderRadius: 'var(--style-radius-s)',
803
+ color: 'var(--theme-text)',
804
+ fontSize: 'var(--font-size-h4)',
805
+ fontFamily: 'monospace',
806
+ textAlign: 'center',
807
+ letterSpacing: '0.5em',
808
+ outline: 'none',
809
+ boxSizing: 'border-box'
810
+ }
811
+ })
812
+ ]
813
+ }),
814
+ error && /*#__PURE__*/ _jsx("div", {
815
+ style: {
816
+ color: 'var(--theme-error-500)',
817
+ marginBottom: 'var(--base)',
818
+ fontSize: 'var(--font-size-small)',
819
+ padding: 'calc(var(--base) * 0.5)',
820
+ background: 'var(--theme-error-50)',
821
+ borderRadius: 'var(--style-radius-s)',
822
+ border: '1px solid var(--theme-error-200)'
823
+ },
824
+ children: error
825
+ }),
826
+ /*#__PURE__*/ _jsx("button", {
827
+ type: "submit",
828
+ disabled: otpLoading || otp.length !== 6,
829
+ style: {
830
+ width: '100%',
831
+ padding: 'calc(var(--base) * 0.75)',
832
+ background: 'var(--theme-elevation-800)',
833
+ border: 'none',
834
+ borderRadius: 'var(--style-radius-s)',
835
+ color: 'var(--theme-elevation-50)',
836
+ fontSize: 'var(--font-size-base)',
837
+ fontWeight: 500,
838
+ cursor: otpLoading || otp.length !== 6 ? 'not-allowed' : 'pointer',
839
+ opacity: otpLoading || otp.length !== 6 ? 0.7 : 1,
840
+ transition: 'opacity 150ms ease'
841
+ },
842
+ children: otpLoading ? 'Verifying...' : 'Verify'
843
+ })
844
+ ]
845
+ }),
846
+ /*#__PURE__*/ _jsx("button", {
847
+ type: "button",
848
+ onClick: handleBackToLogin,
849
+ style: {
850
+ width: '100%',
851
+ marginTop: 'var(--base)',
852
+ padding: 'calc(var(--base) * 0.5)',
853
+ background: 'transparent',
854
+ border: 'none',
855
+ color: 'var(--theme-text)',
856
+ opacity: 0.7,
857
+ fontSize: 'var(--font-size-small)',
858
+ cursor: 'pointer'
859
+ },
860
+ children: "← Back to login"
861
+ })
862
+ ]
863
+ })
864
+ });
865
+ }
595
866
  // Registration view
596
867
  if (viewMode === 'register') {
597
868
  return /*#__PURE__*/ _jsx("div", {
@@ -1076,6 +1347,129 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1076
1347
  })
1077
1348
  });
1078
1349
  }
1350
+ // Magic-link sent confirmation view
1351
+ if (viewMode === 'magicLinkSent') {
1352
+ return /*#__PURE__*/ _jsx("div", {
1353
+ style: {
1354
+ minHeight: '100vh',
1355
+ display: 'flex',
1356
+ alignItems: 'center',
1357
+ justifyContent: 'center',
1358
+ background: 'var(--theme-bg)',
1359
+ padding: 'var(--base)'
1360
+ },
1361
+ children: /*#__PURE__*/ _jsxs("div", {
1362
+ style: {
1363
+ background: 'var(--theme-elevation-50)',
1364
+ padding: 'calc(var(--base) * 2)',
1365
+ borderRadius: 'var(--style-radius-m)',
1366
+ boxShadow: '0 2px 20px rgba(0, 0, 0, 0.1)',
1367
+ width: '100%',
1368
+ maxWidth: '400px',
1369
+ textAlign: 'center'
1370
+ },
1371
+ children: [
1372
+ logo && /*#__PURE__*/ _jsx("div", {
1373
+ style: {
1374
+ marginBottom: 'calc(var(--base) * 1.5)'
1375
+ },
1376
+ children: logo
1377
+ }),
1378
+ /*#__PURE__*/ _jsx("div", {
1379
+ style: {
1380
+ width: '64px',
1381
+ height: '64px',
1382
+ background: 'var(--theme-success-100)',
1383
+ borderRadius: '50%',
1384
+ display: 'flex',
1385
+ alignItems: 'center',
1386
+ justifyContent: 'center',
1387
+ margin: '0 auto calc(var(--base) * 1.5)',
1388
+ fontSize: '28px'
1389
+ },
1390
+ children: "✉"
1391
+ }),
1392
+ /*#__PURE__*/ _jsx("h1", {
1393
+ style: {
1394
+ color: 'var(--theme-text)',
1395
+ fontSize: 'var(--font-size-h3)',
1396
+ fontWeight: 600,
1397
+ margin: '0 0 calc(var(--base) * 0.5) 0'
1398
+ },
1399
+ children: "Check Your Email"
1400
+ }),
1401
+ /*#__PURE__*/ _jsxs("p", {
1402
+ style: {
1403
+ color: 'var(--theme-text)',
1404
+ opacity: 0.7,
1405
+ fontSize: 'var(--font-size-small)',
1406
+ marginBottom: 'calc(var(--base) * 1.5)'
1407
+ },
1408
+ children: [
1409
+ "We've sent a sign-in link to ",
1410
+ /*#__PURE__*/ _jsx("strong", {
1411
+ children: email
1412
+ })
1413
+ ]
1414
+ }),
1415
+ /*#__PURE__*/ _jsx("button", {
1416
+ type: "button",
1417
+ onClick: handleBackToLogin,
1418
+ style: {
1419
+ padding: 'calc(var(--base) * 0.75) calc(var(--base) * 1.5)',
1420
+ background: 'var(--theme-elevation-150)',
1421
+ border: 'none',
1422
+ borderRadius: 'var(--style-radius-s)',
1423
+ color: 'var(--theme-text)',
1424
+ fontSize: 'var(--font-size-base)',
1425
+ cursor: 'pointer'
1426
+ },
1427
+ children: "Back to login"
1428
+ })
1429
+ ]
1430
+ })
1431
+ });
1432
+ }
1433
+ // Resolve which methods are available and which owns the primary action
1434
+ const passwordAvailable = resolveAvailability(enablePassword, passwordProbe);
1435
+ const magicLinkAvailable = resolveAvailability(enableMagicLink, magicLinkProbe);
1436
+ const emailOtpAvailable = resolveAvailability(enableEmailOtp, emailOtpProbe);
1437
+ const primaryMethod = pickPrimaryMethod({
1438
+ password: passwordAvailable,
1439
+ magicLink: magicLinkAvailable,
1440
+ emailOtp: emailOtpAvailable
1441
+ });
1442
+ const primarySubmit = primaryMethod === 'magicLink' ? handleSendMagicLink : primaryMethod === 'emailOtp' ? handleSendEmailOtp : handleSubmit;
1443
+ const primaryLabel = loading ? primaryMethod === 'password' ? 'Signing in...' : 'Sending...' : primaryMethod === 'magicLink' ? 'Email me a link' : primaryMethod === 'emailOtp' ? 'Email me a code' : 'Sign In';
1444
+ // Secondary methods shown under the "or" divider (available but not the primary)
1445
+ const secondaryMethods = [];
1446
+ if (passkeyAvailable) {
1447
+ secondaryMethods.push({
1448
+ key: 'passkey',
1449
+ icon: '🔐',
1450
+ label: passkeyLoading ? 'Authenticating...' : 'Sign in with Passkey',
1451
+ onClick: handlePasskeySignIn,
1452
+ busy: passkeyLoading
1453
+ });
1454
+ }
1455
+ if (magicLinkAvailable && primaryMethod !== 'magicLink') {
1456
+ secondaryMethods.push({
1457
+ key: 'magicLink',
1458
+ icon: '✉',
1459
+ label: 'Email me a link',
1460
+ onClick: ()=>handleSendMagicLink(),
1461
+ busy: false
1462
+ });
1463
+ }
1464
+ if (emailOtpAvailable && primaryMethod !== 'emailOtp') {
1465
+ secondaryMethods.push({
1466
+ key: 'emailOtp',
1467
+ icon: '#️⃣',
1468
+ label: 'Email me a code',
1469
+ onClick: ()=>handleSendEmailOtp(),
1470
+ busy: false
1471
+ });
1472
+ }
1079
1473
  // Main login view
1080
1474
  return /*#__PURE__*/ _jsx("div", {
1081
1475
  style: {
@@ -1126,7 +1520,7 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1126
1520
  children: successMessage
1127
1521
  }),
1128
1522
  /*#__PURE__*/ _jsxs("form", {
1129
- onSubmit: handleSubmit,
1523
+ onSubmit: primarySubmit,
1130
1524
  children: [
1131
1525
  /*#__PURE__*/ _jsxs("div", {
1132
1526
  style: {
@@ -1165,64 +1559,68 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1165
1559
  })
1166
1560
  ]
1167
1561
  }),
1168
- /*#__PURE__*/ _jsxs("div", {
1169
- style: {
1170
- marginBottom: 'var(--base)'
1171
- },
1562
+ passwordAvailable && /*#__PURE__*/ _jsxs(_Fragment, {
1172
1563
  children: [
1173
- /*#__PURE__*/ _jsx("label", {
1174
- htmlFor: "password",
1564
+ /*#__PURE__*/ _jsxs("div", {
1175
1565
  style: {
1176
- display: 'block',
1177
- color: 'var(--theme-text)',
1178
- marginBottom: 'calc(var(--base) * 0.5)',
1179
- fontSize: 'var(--font-size-small)',
1180
- fontWeight: 500
1566
+ marginBottom: 'var(--base)'
1181
1567
  },
1182
- children: "Password"
1568
+ children: [
1569
+ /*#__PURE__*/ _jsx("label", {
1570
+ htmlFor: "password",
1571
+ style: {
1572
+ display: 'block',
1573
+ color: 'var(--theme-text)',
1574
+ marginBottom: 'calc(var(--base) * 0.5)',
1575
+ fontSize: 'var(--font-size-small)',
1576
+ fontWeight: 500
1577
+ },
1578
+ children: "Password"
1579
+ }),
1580
+ /*#__PURE__*/ _jsx("input", {
1581
+ id: "password",
1582
+ type: "password",
1583
+ value: password,
1584
+ onChange: (e)=>setPassword(e.target.value),
1585
+ required: true,
1586
+ autoComplete: "current-password",
1587
+ style: {
1588
+ width: '100%',
1589
+ padding: 'calc(var(--base) * 0.75)',
1590
+ background: 'var(--theme-input-bg)',
1591
+ border: '1px solid var(--theme-elevation-150)',
1592
+ borderRadius: 'var(--style-radius-s)',
1593
+ color: 'var(--theme-text)',
1594
+ fontSize: 'var(--font-size-base)',
1595
+ outline: 'none',
1596
+ boxSizing: 'border-box'
1597
+ }
1598
+ })
1599
+ ]
1183
1600
  }),
1184
- /*#__PURE__*/ _jsx("input", {
1185
- id: "password",
1186
- type: "password",
1187
- value: password,
1188
- onChange: (e)=>setPassword(e.target.value),
1189
- required: true,
1190
- autoComplete: "current-password",
1601
+ forgotPasswordAvailable && /*#__PURE__*/ _jsx("div", {
1191
1602
  style: {
1192
- width: '100%',
1193
- padding: 'calc(var(--base) * 0.75)',
1194
- background: 'var(--theme-input-bg)',
1195
- border: '1px solid var(--theme-elevation-150)',
1196
- borderRadius: 'var(--style-radius-s)',
1197
- color: 'var(--theme-text)',
1198
- fontSize: 'var(--font-size-base)',
1199
- outline: 'none',
1200
- boxSizing: 'border-box'
1201
- }
1603
+ marginBottom: 'calc(var(--base) * 1.5)',
1604
+ textAlign: 'right'
1605
+ },
1606
+ children: /*#__PURE__*/ _jsx("button", {
1607
+ type: "button",
1608
+ onClick: ()=>switchView('forgotPassword'),
1609
+ style: {
1610
+ background: 'none',
1611
+ border: 'none',
1612
+ color: 'var(--theme-text)',
1613
+ opacity: 0.7,
1614
+ cursor: 'pointer',
1615
+ fontSize: 'var(--font-size-small)',
1616
+ padding: 0,
1617
+ textDecoration: 'underline'
1618
+ },
1619
+ children: "Forgot password?"
1620
+ })
1202
1621
  })
1203
1622
  ]
1204
1623
  }),
1205
- forgotPasswordAvailable && /*#__PURE__*/ _jsx("div", {
1206
- style: {
1207
- marginBottom: 'calc(var(--base) * 1.5)',
1208
- textAlign: 'right'
1209
- },
1210
- children: /*#__PURE__*/ _jsx("button", {
1211
- type: "button",
1212
- onClick: ()=>switchView('forgotPassword'),
1213
- style: {
1214
- background: 'none',
1215
- border: 'none',
1216
- color: 'var(--theme-text)',
1217
- opacity: 0.7,
1218
- cursor: 'pointer',
1219
- fontSize: 'var(--font-size-small)',
1220
- padding: 0,
1221
- textDecoration: 'underline'
1222
- },
1223
- children: "Forgot password?"
1224
- })
1225
- }),
1226
1624
  error && /*#__PURE__*/ _jsx("div", {
1227
1625
  style: {
1228
1626
  color: 'var(--theme-error-500)',
@@ -1251,11 +1649,11 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1251
1649
  opacity: loading || passkeyLoading ? 0.7 : 1,
1252
1650
  transition: 'opacity 150ms ease'
1253
1651
  },
1254
- children: loading ? 'Signing in...' : 'Sign In'
1652
+ children: primaryLabel
1255
1653
  })
1256
1654
  ]
1257
1655
  }),
1258
- passkeyAvailable && /*#__PURE__*/ _jsxs(_Fragment, {
1656
+ secondaryMethods.length > 0 && /*#__PURE__*/ _jsxs(_Fragment, {
1259
1657
  children: [
1260
1658
  /*#__PURE__*/ _jsxs("div", {
1261
1659
  style: {
@@ -1289,39 +1687,50 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1289
1687
  })
1290
1688
  ]
1291
1689
  }),
1292
- /*#__PURE__*/ _jsxs("button", {
1293
- type: "button",
1294
- onClick: handlePasskeySignIn,
1295
- disabled: loading || passkeyLoading,
1296
- style: {
1297
- width: '100%',
1298
- padding: 'calc(var(--base) * 0.75)',
1299
- background: 'transparent',
1300
- border: '1px solid var(--theme-elevation-300)',
1301
- borderRadius: 'var(--style-radius-s)',
1302
- color: 'var(--theme-text)',
1303
- fontSize: 'var(--font-size-base)',
1304
- fontWeight: 500,
1305
- cursor: loading || passkeyLoading ? 'not-allowed' : 'pointer',
1306
- opacity: loading || passkeyLoading ? 0.7 : 1,
1307
- transition: 'opacity 150ms ease',
1308
- display: 'flex',
1309
- alignItems: 'center',
1310
- justifyContent: 'center',
1311
- gap: 'calc(var(--base) * 0.5)'
1312
- },
1313
- children: [
1314
- /*#__PURE__*/ _jsx("span", {
1315
- style: {
1316
- fontSize: '18px'
1317
- },
1318
- children: "🔐"
1319
- }),
1320
- passkeyLoading ? 'Authenticating...' : 'Sign in with Passkey'
1321
- ]
1322
- })
1690
+ secondaryMethods.map((method)=>/*#__PURE__*/ _jsxs("button", {
1691
+ type: "button",
1692
+ onClick: method.onClick,
1693
+ disabled: loading || passkeyLoading || method.busy,
1694
+ style: {
1695
+ width: '100%',
1696
+ marginBottom: 'calc(var(--base) * 0.5)',
1697
+ padding: 'calc(var(--base) * 0.75)',
1698
+ background: 'transparent',
1699
+ border: '1px solid var(--theme-elevation-300)',
1700
+ borderRadius: 'var(--style-radius-s)',
1701
+ color: 'var(--theme-text)',
1702
+ fontSize: 'var(--font-size-base)',
1703
+ fontWeight: 500,
1704
+ cursor: loading || passkeyLoading || method.busy ? 'not-allowed' : 'pointer',
1705
+ opacity: loading || passkeyLoading || method.busy ? 0.7 : 1,
1706
+ transition: 'opacity 150ms ease',
1707
+ display: 'flex',
1708
+ alignItems: 'center',
1709
+ justifyContent: 'center',
1710
+ gap: 'calc(var(--base) * 0.5)'
1711
+ },
1712
+ children: [
1713
+ /*#__PURE__*/ _jsx("span", {
1714
+ style: {
1715
+ fontSize: '18px'
1716
+ },
1717
+ children: method.icon
1718
+ }),
1719
+ method.label
1720
+ ]
1721
+ }, method.key))
1323
1722
  ]
1324
1723
  }),
1724
+ primaryMethod === null && secondaryMethods.length === 0 && /*#__PURE__*/ _jsx("p", {
1725
+ style: {
1726
+ marginTop: 'var(--base)',
1727
+ textAlign: 'center',
1728
+ fontSize: 'var(--font-size-small)',
1729
+ color: 'var(--theme-text)',
1730
+ opacity: 0.7
1731
+ },
1732
+ children: "No sign-in methods are currently enabled."
1733
+ }),
1325
1734
  signUpAvailable && /*#__PURE__*/ _jsxs("div", {
1326
1735
  style: {
1327
1736
  marginTop: 'calc(var(--base) * 1.5)',
@@ -18,6 +18,10 @@ import { LoginView } from './LoginView.js';
18
18
  defaultSignUpRole: loginConfig.defaultSignUpRole,
19
19
  enableForgotPassword: loginConfig.enableForgotPassword,
20
20
  resetPasswordUrl: loginConfig.resetPasswordUrl,
21
+ enablePassword: loginConfig.enablePassword,
22
+ enableMagicLink: loginConfig.enableMagicLink,
23
+ enableEmailOtp: loginConfig.enableEmailOtp,
24
+ magicLinkCallbackURL: loginConfig.magicLinkCallbackURL,
21
25
  title: loginConfig.title
22
26
  });
23
27
  }
@@ -17,6 +17,7 @@ import { createPayloadAuthClient } from '../../exports/client.js';
17
17
  const [backupCodes, setBackupCodes] = useState([]);
18
18
  const [verificationCode, setVerificationCode] = useState('');
19
19
  const [password, setPassword] = useState('');
20
+ const [passwordAction, setPasswordAction] = useState('enable');
20
21
  const [actionLoading, setActionLoading] = useState(false);
21
22
  const getClient = ()=>providedClient ?? createPayloadAuthClient();
22
23
  useEffect(()=>{
@@ -41,10 +42,18 @@ import { createPayloadAuthClient } from '../../exports/client.js';
41
42
  }
42
43
  function handleEnableClick() {
43
44
  // Show password prompt first
45
+ setPasswordAction('enable');
44
46
  setStep('password');
45
47
  setPassword('');
46
48
  setError(null);
47
49
  }
50
+ function handlePasswordContinue() {
51
+ if (passwordAction === 'disable') {
52
+ void handleDisableWithPassword();
53
+ } else {
54
+ void handleEnableWithPassword();
55
+ }
56
+ }
48
57
  async function handleEnableWithPassword() {
49
58
  setActionLoading(true);
50
59
  setError(null);
@@ -95,21 +104,32 @@ import { createPayloadAuthClient } from '../../exports/client.js';
95
104
  setActionLoading(false);
96
105
  }
97
106
  }
98
- async function handleDisable() {
107
+ function handleDisableClick() {
99
108
  if (!confirm('Are you sure you want to disable two-factor authentication?')) {
100
109
  return;
101
110
  }
111
+ // Better Auth's /two-factor/disable requires the account password.
112
+ // Prompt for it instead of sending an empty string (which fails with
113
+ // "Invalid password" before the real password is ever checked).
114
+ setPasswordAction('disable');
115
+ setStep('password');
116
+ setPassword('');
117
+ setError(null);
118
+ }
119
+ async function handleDisableWithPassword() {
102
120
  setActionLoading(true);
103
121
  setError(null);
104
122
  try {
105
123
  const client = getClient();
106
124
  const result = await client.twoFactor.disable({
107
- password: ''
125
+ password
108
126
  });
109
127
  if (result.error) {
110
128
  setError(result.error.message ?? 'Failed to disable 2FA');
111
129
  } else {
112
130
  setIsEnabled(false);
131
+ setPassword('');
132
+ setStep('status');
113
133
  onComplete?.();
114
134
  }
115
135
  } catch {
@@ -153,7 +173,7 @@ import { createPayloadAuthClient } from '../../exports/client.js';
153
173
  /*#__PURE__*/ _jsx(Button, {
154
174
  buttonStyle: isEnabled ? 'error' : 'secondary',
155
175
  size: "small",
156
- onClick: isEnabled ? handleDisable : handleEnableClick,
176
+ onClick: isEnabled ? handleDisableClick : handleEnableClick,
157
177
  disabled: actionLoading,
158
178
  children: actionLoading ? 'Loading...' : isEnabled ? 'Disable' : 'Enable'
159
179
  })
@@ -161,9 +181,13 @@ import { createPayloadAuthClient } from '../../exports/client.js';
161
181
  }),
162
182
  step === 'password' && /*#__PURE__*/ _jsxs("div", {
163
183
  children: [
164
- /*#__PURE__*/ _jsx("p", {
184
+ /*#__PURE__*/ _jsxs("p", {
165
185
  className: "field-description",
166
- children: "Enter your password to enable two-factor authentication."
186
+ children: [
187
+ "Enter your password to ",
188
+ passwordAction === 'disable' ? 'disable' : 'enable',
189
+ " two-factor authentication."
190
+ ]
167
191
  }),
168
192
  /*#__PURE__*/ _jsx("input", {
169
193
  type: "password",
@@ -172,7 +196,7 @@ import { createPayloadAuthClient } from '../../exports/client.js';
172
196
  onKeyDown: (e)=>{
173
197
  if (e.key === 'Enter' && password) {
174
198
  e.preventDefault();
175
- handleEnableWithPassword();
199
+ handlePasswordContinue();
176
200
  }
177
201
  },
178
202
  placeholder: "Enter your password",
@@ -198,9 +222,9 @@ import { createPayloadAuthClient } from '../../exports/client.js';
198
222
  /*#__PURE__*/ _jsx(Button, {
199
223
  buttonStyle: "primary",
200
224
  size: "small",
201
- onClick: handleEnableWithPassword,
225
+ onClick: handlePasswordContinue,
202
226
  disabled: actionLoading || !password,
203
- children: actionLoading ? 'Enabling...' : 'Continue'
227
+ children: actionLoading ? passwordAction === 'disable' ? 'Disabling...' : 'Enabling...' : 'Continue'
204
228
  }),
205
229
  /*#__PURE__*/ _jsx(Button, {
206
230
  buttonStyle: "secondary",
@@ -69,6 +69,36 @@ export type BetterAuthPluginAdminOptions = {
69
69
  * instead of showing the inline password reset form.
70
70
  */
71
71
  resetPasswordUrl?: string;
72
+ /**
73
+ * Enable email + password sign-in.
74
+ * - true: Always show the password field
75
+ * - false: Hide the password field (passwordless-only)
76
+ * - 'auto': Auto-detect via the /sign-in/email endpoint
77
+ * Default: 'auto' - LoginView hides the password field when the email/password
78
+ * strategy is disabled in Better Auth.
79
+ */
80
+ enablePassword?: boolean | 'auto';
81
+ /**
82
+ * Enable magic-link sign-in ("email me a link").
83
+ * - true: Always show the magic-link option
84
+ * - false: Never show it
85
+ * - 'auto': Auto-detect via the /sign-in/magic-link endpoint
86
+ * Default: 'auto' - requires the Better Auth magicLink() plugin.
87
+ */
88
+ enableMagicLink?: boolean | 'auto';
89
+ /**
90
+ * Enable email-OTP sign-in ("email me a code").
91
+ * - true: Always show the email-OTP option
92
+ * - false: Never show it
93
+ * - 'auto': Auto-detect via the /email-otp/send-verification-otp endpoint
94
+ * Default: 'auto' - requires the Better Auth emailOTP() plugin.
95
+ */
96
+ enableEmailOtp?: boolean | 'auto';
97
+ /**
98
+ * Where the emailed magic link returns after verification.
99
+ * Default: afterLoginPath
100
+ */
101
+ magicLinkCallbackURL?: string;
72
102
  };
73
103
  /** Path to custom logout button component (import map format) */
74
104
  logoutButtonComponent?: string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure helpers for deciding which sign-in methods the LoginView should display.
3
+ * Kept DOM-free so they can be unit-tested under the project's node-env vitest setup.
4
+ */
5
+ /** A method-enable setting: explicit boolean, or 'auto' to defer to an endpoint probe. */
6
+ export type MethodSetting = boolean | 'auto';
7
+ /**
8
+ * Resolve a `boolean | 'auto'` setting against the result of an endpoint probe.
9
+ *
10
+ * - `true` -> always available
11
+ * - `false` -> never available
12
+ * - `'auto'` -> available iff the probe succeeded (`probeOk === true`); a `null`
13
+ * probe (not yet completed) resolves to `false`.
14
+ */
15
+ export declare function resolveAvailability(setting: MethodSetting, probeOk: boolean | null): boolean;
16
+ /** The sign-in methods that can own the primary submit button. */
17
+ export type PrimaryMethod = 'password' | 'magicLink' | 'emailOtp';
18
+ /**
19
+ * Choose which available method owns the primary submit button.
20
+ * Precedence: password -> magicLink -> emailOtp. Returns null if none are available.
21
+ */
22
+ export declare function pickPrimaryMethod(available: {
23
+ password: boolean;
24
+ magicLink: boolean;
25
+ emailOtp: boolean;
26
+ }): PrimaryMethod | null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure helpers for deciding which sign-in methods the LoginView should display.
3
+ * Kept DOM-free so they can be unit-tested under the project's node-env vitest setup.
4
+ */ /** A method-enable setting: explicit boolean, or 'auto' to defer to an endpoint probe. */ /**
5
+ * Resolve a `boolean | 'auto'` setting against the result of an endpoint probe.
6
+ *
7
+ * - `true` -> always available
8
+ * - `false` -> never available
9
+ * - `'auto'` -> available iff the probe succeeded (`probeOk === true`); a `null`
10
+ * probe (not yet completed) resolves to `false`.
11
+ */ export function resolveAvailability(setting, probeOk) {
12
+ if (setting === true) return true;
13
+ if (setting === false) return false;
14
+ return probeOk === true;
15
+ }
16
+ /**
17
+ * Choose which available method owns the primary submit button.
18
+ * Precedence: password -> magicLink -> emailOtp. Returns null if none are available.
19
+ */ export function pickPrimaryMethod(available) {
20
+ if (available.password) return 'password';
21
+ if (available.magicLink) return 'magicLink';
22
+ if (available.emailOtp) return 'emailOtp';
23
+ return null;
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,23 +113,24 @@
113
113
  }
114
114
  },
115
115
  "devDependencies": {
116
- "@better-auth/api-key": "^1.6.10",
117
- "@better-auth/oauth-provider": "^1.6.10",
118
- "@better-auth/passkey": "^1.6.10",
119
- "@payloadcms/next": "^3.84.1",
120
- "@payloadcms/ui": "^3.84.1",
116
+ "@better-auth/api-key": "^1.6.17",
117
+ "@better-auth/oauth-provider": "^1.6.17",
118
+ "@better-auth/passkey": "^1.6.17",
119
+ "@payloadcms/next": "^3.85.0",
120
+ "@payloadcms/ui": "^3.85.0",
121
121
  "@swc/cli": "^0.6.0",
122
122
  "@swc/core": "^1.15.30",
123
123
  "@types/node": "^24.12.2",
124
124
  "@types/react": "^19.2.14",
125
- "@vitest/coverage-v8": "^2.1.9",
126
- "better-auth": "^1.6.10",
125
+ "@vitest/coverage-v8": "^4.1.8",
126
+ "better-auth": "^1.6.17",
127
127
  "next": "^16.2.5",
128
- "payload": "^3.84.1",
128
+ "payload": "^3.85.0",
129
129
  "react": "^19.2.5",
130
130
  "tsx": "^4.21.0",
131
131
  "typescript": "^5.9.3",
132
- "vitest": "^2.1.9"
132
+ "vite": "^7.3.5",
133
+ "vitest": "^4.1.8"
133
134
  },
134
135
  "keywords": [
135
136
  "payload",