@delmaredigital/payload-better-auth 0.7.5 → 0.7.7

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.
package/README.md CHANGED
@@ -178,7 +178,7 @@ export default async function Dashboard() {
178
178
 
179
179
  ---
180
180
 
181
- For MongoDB setup, API reference, customization, access control helpers, API key scopes, plugin compatibility, UI components (2FA, passkeys, password reset), recipes, and types — see the **[full documentation](https://delmaredigital.github.io/payload-better-auth/)**.
181
+ For MongoDB setup, API reference, customization, access control helpers, API key scopes, plugin compatibility, UI components (2FA, passkeys, password reset, passwordless login via magic-link & email-OTP), recipes, and types — see the **[full documentation](https://delmaredigital.github.io/payload-better-auth/)**.
182
182
 
183
183
  ## License
184
184
 
@@ -25,7 +25,7 @@ import { useConfig } from '@payloadcms/ui';
25
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 }) {
26
26
  const router = useRouter();
27
27
  // Payload Config
28
- const { config: { routes: { admin: adminRoute, api: apiRoute } } } = useConfig();
28
+ const { config: { routes: { admin: adminRoute } } } = useConfig();
29
29
  // View state
30
30
  const [viewMode, setViewMode] = useState('login');
31
31
  // Form fields
@@ -40,16 +40,18 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
40
40
  const [passkeyLoading, setPasskeyLoading] = useState(false);
41
41
  const [checkingSession, setCheckingSession] = useState(true);
42
42
  const [accessDenied, setAccessDenied] = useState(false);
43
- // Feature availability
44
- const [passkeyAvailable, setPasskeyAvailable] = useState(enablePasskey === true);
45
- const [signUpAvailable, setSignUpAvailable] = useState(enableSignUp === true);
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);
43
+ // Which methods to show. LoginViewWrapper resolves these server-side from the
44
+ // Better Auth instance's options and passes concrete booleans. For standalone
45
+ // <LoginView> use, an unresolved 'auto' falls back to safe defaults: password
46
+ // shown, optional methods hidden. We no longer probe endpoints — Better Auth
47
+ // answers every OPTIONS request with 200 (CORS preflight), so probing could
48
+ // never tell whether a method was actually enabled.
49
+ const passwordAvailable = resolveAvailability(enablePassword, true);
50
+ const passkeyAvailable = resolveAvailability(enablePasskey, false);
51
+ const signUpAvailable = resolveAvailability(enableSignUp, false);
52
+ const forgotPasswordAvailable = resolveAvailability(enableForgotPassword, false);
53
+ const magicLinkAvailable = resolveAvailability(enableMagicLink, false);
54
+ const emailOtpAvailable = resolveAvailability(enableEmailOtp, false);
53
55
  // Email-OTP code entry state
54
56
  const [otp, setOtp] = useState('');
55
57
  const [otpLoading, setOtpLoading] = useState(false);
@@ -101,97 +103,6 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
101
103
  requireAllRoles,
102
104
  router
103
105
  ]);
104
- // Auto-detect passkey availability if set to 'auto'
105
- useEffect(()=>{
106
- if (enablePasskey === 'auto') {
107
- // Check if passkey endpoint exists (GET request)
108
- // Better Auth passkey routes are at /passkey/* (singular)
109
- fetch(`${apiRoute}/auth/passkey/generate-authenticate-options`, {
110
- method: 'GET',
111
- credentials: 'include'
112
- }).then((res)=>{
113
- // If we get a response (even 400/401 for not authenticated), passkey is available
114
- // 404 means passkey plugin is not installed
115
- setPasskeyAvailable(res.status !== 404);
116
- }).catch(()=>{
117
- setPasskeyAvailable(false);
118
- });
119
- } else {
120
- setPasskeyAvailable(enablePasskey === true);
121
- }
122
- }, [
123
- enablePasskey
124
- ]);
125
- // Auto-detect sign up availability if set to 'auto'
126
- useEffect(()=>{
127
- if (enableSignUp === 'auto') {
128
- // Check if sign-up endpoint exists
129
- fetch(`${apiRoute}/auth/sign-up/email`, {
130
- method: 'OPTIONS',
131
- credentials: 'include'
132
- }).then((res)=>{
133
- // 404 means sign-up is not available
134
- setSignUpAvailable(res.status !== 404);
135
- }).catch(()=>{
136
- // If OPTIONS fails, try a HEAD or just assume it's available since it's a core endpoint
137
- setSignUpAvailable(true);
138
- });
139
- } else {
140
- setSignUpAvailable(enableSignUp === true);
141
- }
142
- }, [
143
- enableSignUp
144
- ]);
145
- // Auto-detect forgot password availability if set to 'auto'
146
- useEffect(()=>{
147
- if (enableForgotPassword === 'auto') {
148
- // Check if request-password-reset endpoint exists
149
- fetch(`${apiRoute}/auth/request-password-reset`, {
150
- method: 'OPTIONS',
151
- credentials: 'include'
152
- }).then((res)=>{
153
- // 404 means request-password-reset is not available
154
- setForgotPasswordAvailable(res.status !== 404);
155
- }).catch(()=>{
156
- // If OPTIONS fails, assume it's available since it's a core endpoint
157
- setForgotPasswordAvailable(true);
158
- });
159
- } else {
160
- setForgotPasswordAvailable(enableForgotPassword === true);
161
- }
162
- }, [
163
- enableForgotPassword
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
106
  /**
196
107
  * Shared post-authentication tail: re-fetch the session for complete user data
197
108
  * (e.g. roles applied by hooks), enforce the role gate, and redirect on success.
@@ -1430,10 +1341,7 @@ export function LoginView({ authClient: providedClient, logo, title = 'Login', a
1430
1341
  })
1431
1342
  });
1432
1343
  }
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);
1344
+ // Which method owns the primary submit button (availability resolved above)
1437
1345
  const primaryMethod = pickPrimaryMethod({
1438
1346
  password: passwordAvailable,
1439
1347
  magicLink: magicLinkAvailable,
@@ -2,8 +2,15 @@ import type { AdminViewProps } from 'payload';
2
2
  type LoginViewWrapperProps = AdminViewProps;
3
3
  /**
4
4
  * Server component wrapper for LoginView.
5
- * Reads login configuration from payload.config.custom.betterAuth.login
6
- * and passes it as props to the client LoginView component.
5
+ *
6
+ * Reads login configuration from `payload.config.custom.betterAuth.login` and
7
+ * resolves each `'auto'` option against the Better Auth instance's resolved
8
+ * `options` (server-side), passing concrete booleans to the client LoginView.
9
+ *
10
+ * This replaces the old client-side `OPTIONS` endpoint probing: Better Auth
11
+ * answers every `OPTIONS` request with 200 (CORS preflight), so probing could
12
+ * never determine whether a method was actually enabled. The server `options`
13
+ * are authoritative.
7
14
  */
8
15
  export declare function LoginViewWrapper({ initPageResult }: LoginViewWrapperProps): Promise<import("react").JSX.Element>;
9
16
  export default LoginViewWrapper;
@@ -1,26 +1,51 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { LoginView } from './LoginView.js';
3
+ import { detectEnabledMethods, resolveAvailability } from '../utils/loginMethods.js';
4
+ /**
5
+ * Fallback used when the Better Auth instance isn't available on `payload` yet.
6
+ * Show password (so admins are never locked out) and hide the optional methods
7
+ * (so we never render a button for a plugin that may be absent).
8
+ */ const FALLBACK_DETECTED = {
9
+ password: true,
10
+ signup: false,
11
+ forgotPassword: false,
12
+ passkey: false,
13
+ magicLink: false,
14
+ emailOtp: false
15
+ };
3
16
  /**
4
17
  * Server component wrapper for LoginView.
5
- * Reads login configuration from payload.config.custom.betterAuth.login
6
- * and passes it as props to the client LoginView component.
18
+ *
19
+ * Reads login configuration from `payload.config.custom.betterAuth.login` and
20
+ * resolves each `'auto'` option against the Better Auth instance's resolved
21
+ * `options` (server-side), passing concrete booleans to the client LoginView.
22
+ *
23
+ * This replaces the old client-side `OPTIONS` endpoint probing: Better Auth
24
+ * answers every `OPTIONS` request with 200 (CORS preflight), so probing could
25
+ * never determine whether a method was actually enabled. The server `options`
26
+ * are authoritative.
7
27
  */ export async function LoginViewWrapper({ initPageResult }) {
8
28
  const { req } = initPageResult;
9
29
  const { payload } = req;
10
30
  // Read login config from payload.config.custom.betterAuth.login
11
31
  const loginConfig = payload.config.custom?.betterAuth?.login ?? {};
32
+ // Detect which methods Better Auth actually has enabled, from its resolved options.
33
+ const authOptions = payload.betterAuth?.options;
34
+ const detected = authOptions ? detectEnabledMethods(authOptions) : FALLBACK_DETECTED;
35
+ // Resolve each option: explicit boolean wins; 'auto' (or unset) uses detection.
36
+ const resolve = (setting, detectedValue)=>resolveAvailability(setting ?? 'auto', detectedValue);
12
37
  return /*#__PURE__*/ _jsx(LoginView, {
13
38
  afterLoginPath: loginConfig.afterLoginPath,
14
39
  requiredRole: loginConfig.requiredRole,
15
40
  requireAllRoles: loginConfig.requireAllRoles,
16
- enablePasskey: loginConfig.enablePasskey,
17
- enableSignUp: loginConfig.enableSignUp,
41
+ enablePassword: resolve(loginConfig.enablePassword, detected.password),
42
+ enableSignUp: resolve(loginConfig.enableSignUp, detected.signup),
18
43
  defaultSignUpRole: loginConfig.defaultSignUpRole,
19
- enableForgotPassword: loginConfig.enableForgotPassword,
44
+ enableForgotPassword: resolve(loginConfig.enableForgotPassword, detected.forgotPassword),
45
+ enablePasskey: resolve(loginConfig.enablePasskey, detected.passkey),
46
+ enableMagicLink: resolve(loginConfig.enableMagicLink, detected.magicLink),
47
+ enableEmailOtp: resolve(loginConfig.enableEmailOtp, detected.emailOtp),
20
48
  resetPasswordUrl: loginConfig.resetPasswordUrl,
21
- enablePassword: loginConfig.enablePassword,
22
- enableMagicLink: loginConfig.enableMagicLink,
23
- enableEmailOtp: loginConfig.enableEmailOtp,
24
49
  magicLinkCallbackURL: loginConfig.magicLinkCallbackURL,
25
50
  title: loginConfig.title
26
51
  });
@@ -53,6 +53,10 @@ export { twoFactorClient } from 'better-auth/client/plugins';
53
53
  * })
54
54
  * ```
55
55
  */ export function createPayloadAuthClient(options) {
56
+ // `payloadAuthPlugins` is intentionally widened to `BetterAuthClientPlugin[]` for
57
+ // declaration-emit portability. That widening makes the inferred return type drop
58
+ // some base-client methods (e.g. `refreshToken`, present at runtime) relative to the
59
+ // zero-arg `ReturnType<typeof createAuthClient>`, so we cast back to the stable type.
56
60
  return createAuthClient({
57
61
  baseURL: options?.baseURL ?? (typeof window !== 'undefined' ? window.location.origin : ''),
58
62
  plugins: [
@@ -24,3 +24,38 @@ export declare function pickPrimaryMethod(available: {
24
24
  magicLink: boolean;
25
25
  emailOtp: boolean;
26
26
  }): PrimaryMethod | null;
27
+ /** Which sign-in methods a Better Auth instance actually has enabled. */
28
+ export interface DetectedMethods {
29
+ password: boolean;
30
+ signup: boolean;
31
+ forgotPassword: boolean;
32
+ passkey: boolean;
33
+ magicLink: boolean;
34
+ emailOtp: boolean;
35
+ }
36
+ /**
37
+ * Minimal structural shape of the Better Auth resolved options we read.
38
+ * Declared locally (not imported from better-auth) so this stays dependency-free
39
+ * and unit-testable.
40
+ */
41
+ export interface AuthOptionsLike {
42
+ emailAndPassword?: {
43
+ enabled?: boolean;
44
+ disableSignUp?: boolean;
45
+ sendResetPassword?: unknown;
46
+ };
47
+ plugins?: Array<{
48
+ id?: string;
49
+ } | null | undefined>;
50
+ }
51
+ /**
52
+ * Determine which sign-in methods are enabled from a Better Auth instance's
53
+ * resolved `options`. This is the authoritative, server-side replacement for the
54
+ * old client-side endpoint probing: Better Auth answers every `OPTIONS` request
55
+ * with 200 (CORS preflight), so probing `OPTIONS /sign-in/*` could never tell
56
+ * whether a method was actually enabled.
57
+ *
58
+ * `forgotPassword` requires a configured `sendResetPassword` callback, since the
59
+ * reset flow can't email a link without it.
60
+ */
61
+ export declare function detectEnabledMethods(options: AuthOptionsLike | null | undefined): DetectedMethods;
@@ -22,3 +22,25 @@
22
22
  if (available.emailOtp) return 'emailOtp';
23
23
  return null;
24
24
  }
25
+ /**
26
+ * Determine which sign-in methods are enabled from a Better Auth instance's
27
+ * resolved `options`. This is the authoritative, server-side replacement for the
28
+ * old client-side endpoint probing: Better Auth answers every `OPTIONS` request
29
+ * with 200 (CORS preflight), so probing `OPTIONS /sign-in/*` could never tell
30
+ * whether a method was actually enabled.
31
+ *
32
+ * `forgotPassword` requires a configured `sendResetPassword` callback, since the
33
+ * reset flow can't email a link without it.
34
+ */ export function detectEnabledMethods(options) {
35
+ const ep = options?.emailAndPassword;
36
+ const password = !!ep?.enabled;
37
+ const pluginIds = new Set((options?.plugins ?? []).map((p)=>p?.id).filter((id)=>typeof id === 'string'));
38
+ return {
39
+ password,
40
+ signup: password && !ep?.disableSignUp,
41
+ forgotPassword: password && !!ep?.sendResetPassword,
42
+ passkey: pluginIds.has('passkey'),
43
+ magicLink: pluginIds.has('magic-link'),
44
+ emailOtp: pluginIds.has('email-otp')
45
+ };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,9 +113,9 @@
113
113
  }
114
114
  },
115
115
  "devDependencies": {
116
- "@better-auth/api-key": "^1.6.17",
117
- "@better-auth/oauth-provider": "^1.6.17",
118
- "@better-auth/passkey": "^1.6.17",
116
+ "@better-auth/api-key": "^1.6.18",
117
+ "@better-auth/oauth-provider": "^1.6.18",
118
+ "@better-auth/passkey": "^1.6.18",
119
119
  "@payloadcms/next": "^3.85.0",
120
120
  "@payloadcms/ui": "^3.85.0",
121
121
  "@swc/cli": "^0.6.0",
@@ -123,7 +123,7 @@
123
123
  "@types/node": "^24.12.2",
124
124
  "@types/react": "^19.2.14",
125
125
  "@vitest/coverage-v8": "^4.1.8",
126
- "better-auth": "^1.6.17",
126
+ "better-auth": "^1.6.18",
127
127
  "next": "^16.2.5",
128
128
  "payload": "^3.85.0",
129
129
  "react": "^19.2.5",