From 62838c08b6ee2fb9ef1568f8e7427d70749da92f Mon Sep 17 00:00:00 2001 From: Oleksii Shevchenko Date: Fri, 24 Jul 2026 18:54:57 +0300 Subject: [PATCH] Add config support to login flow --- apps/docs/content/docs/advanced/security.mdx | 5 + .../docs/components/error-boundary.mdx | 2 +- apps/docs/content/docs/components/login.mdx | 91 ++++- apps/docs/content/docs/hooks/use-login.mdx | 104 +++++- .../docs/migration/facebook-login-setup.mdx | 22 +- .../e2e/app/pages/LoginPage.tsx | 6 + .../src/__tests__/Facebook.test.ts | 310 +++++++++++++++++- .../react-facebook/src/components/Login.tsx | 39 ++- packages/react-facebook/src/hooks/useLogin.ts | 10 +- packages/react-facebook/src/utils/Facebook.ts | 44 ++- 10 files changed, 572 insertions(+), 61 deletions(-) diff --git a/apps/docs/content/docs/advanced/security.mdx b/apps/docs/content/docs/advanced/security.mdx index 8c0a06e..8d90b2e 100644 --- a/apps/docs/content/docs/advanced/security.mdx +++ b/apps/docs/content/docs/advanced/security.mdx @@ -47,6 +47,11 @@ function LoginButton() { const handleLogin = async () => { const response = await login({ scope: 'email' }); + if (response.status !== 'connected') return; + + // Narrow to scope flow — authResponse contains accessToken, not code + if (!('accessToken' in response.authResponse)) return; + // Send the token to your server for validation and exchange await fetch('/api/auth/facebook', { method: 'POST', diff --git a/apps/docs/content/docs/components/error-boundary.mdx b/apps/docs/content/docs/components/error-boundary.mdx index 2e4ca5f..5ad4cc7 100644 --- a/apps/docs/content/docs/components/error-boundary.mdx +++ b/apps/docs/content/docs/components/error-boundary.mdx @@ -43,7 +43,7 @@ function App() { }} > - Login with Facebook + Login with Facebook ); diff --git a/apps/docs/content/docs/components/login.mdx b/apps/docs/content/docs/components/login.mdx index 402c627..28b844e 100644 --- a/apps/docs/content/docs/components/login.mdx +++ b/apps/docs/content/docs/components/login.mdx @@ -17,22 +17,25 @@ import { Login } from 'react-facebook'; ## Props -| Prop | Type | Default | Description | -| ------------------ | ---------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------- | -| `children` | `ReactNode \| ((props: LoginRenderProps) => ReactElement)` | `undefined` | Button content, or a render function receiving `{ onClick, loading, isDisabled }`. | -| `onSuccess` | `(response: LoginResponse) => void` | `undefined` | Called after a successful login with the login response containing `authResponse`. | -| `onError` | `(error: Error) => void` | `undefined` | Called when the login fails or the user cancels. | -| `onProfileSuccess` | `(profile: Record) => void` | `undefined` | Called with the user profile when `fields` are provided and the profile is fetched. | -| `scope` | `string \| string[]` | `['public_profile', 'email']` | Permissions to request. Accepts a comma-separated string or an array. | -| `fields` | `string[]` | `[]` | Profile fields to fetch after login (e.g. `['name', 'email', 'picture']`). | -| `as` | `ElementType \| ComponentType` | `'button'` | The HTML element or React component to render. | -| `disabled` | `boolean` | `false` | Disables the login button. | -| `returnScopes` | `boolean` | `undefined` | When `true`, the response includes the scopes the user actually granted. | -| `authType` | `string[]` | `undefined` | Auth type array (e.g. `['rerequest']`). | -| `rerequest` | `boolean` | `undefined` | Adds `'rerequest'` to `authType`, prompting for previously declined permissions. | -| `reauthorize` | `boolean` | `undefined` | Adds `'reauthenticate'` to `authType`, forcing re-authentication. | -| `className` | `string` | `undefined` | CSS class name applied to the rendered element. | -| `style` | `CSSProperties` | `undefined` | Inline styles applied to the rendered element. | +`LoginProps` is a **discriminated union** — you must provide either `configId` or `scope`, not both. Passing both is a TypeScript error. + +| Prop | Type | Default | Description | +| ------------------ | ---------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `children` | `ReactNode \| ((props: LoginRenderProps) => ReactElement)` | `undefined` | Button content, or a render function receiving `{ onClick, loading, isDisabled }`. | +| `onSuccess` | `(response: LoginResponse) => void` | `undefined` | Called after a successful login with the login response containing `authResponse`. | +| `onError` | `(error: Error) => void` | `undefined` | Called when the login fails or the user cancels. | +| `onProfileSuccess` | `(profile: Record) => void` | `undefined` | Called with the user profile when `fields` are provided and the profile is fetched. | +| `configId` | `string` | `undefined` | Facebook Business Login configuration ID. When set, triggers the BISU code flow (`response_type: 'code'`).

Note: Mutually exclusive with `scope`. | +| `scope` | `string \| string[]` | `['public_profile', 'email']` | Permissions to request. Accepts a comma-separated string or an array.

Note: Mutually exclusive with `configId`. | +| `fields` | `string[]` | `[]` | Profile fields to fetch after login (e.g. `['name', 'email', 'picture']`). | +| `as` | `ElementType \| ComponentType` | `'button'` | The HTML element or React component to render. | +| `disabled` | `boolean` | `false` | Disables the login button. | +| `returnScopes` | `boolean` | `undefined` | When `true`, the response includes the scopes the user actually granted. Applies to the `scope` flow only. | +| `authType` | `string[]` | `undefined` | Auth type array (e.g. `['rerequest']`). | +| `rerequest` | `boolean` | `undefined` | Adds `'rerequest'` to `authType`, prompting for previously declined permissions. | +| `reauthorize` | `boolean` | `undefined` | Adds `'reauthenticate'` to `authType`, forcing re-authentication. | +| `className` | `string` | `undefined` | CSS class name applied to the rendered element. | +| `style` | `CSSProperties` | `undefined` | Inline styles applied to the rendered element. | Any additional props are spread onto the rendered element. @@ -117,3 +120,59 @@ When you provide `fields`, the component automatically fetches the user profile Sign in with Facebook ``` + +### Facebook Login for Business (configId) + + + Facebook Login for Business uses a configuration ID created in the [Facebook App Dashboard](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). + Instead of a client-side access token the SDK returns a short-lived authorization **code** that must be exchanged + server-side for a BISU token. See the [Facebook Login for Business docs](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business) + for the full server-side exchange flow. + + +Pass `configId` instead of `scope`. The `onSuccess` callback receives `authResponse.code` — there is no `accessToken` in this flow. + +**With a default button:** + +```tsx + { + if (response.status === 'connected' && 'code' in response.authResponse) { + // Exchange this code on your server + fetch('/api/auth/facebook/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: response.authResponse.code }), + }); + } + }} + onError={(error) => console.error('Login failed:', error)} +> + Continue with Facebook + +``` + +**With the render props pattern:** + +```tsx + { + if (response.status === 'connected' && 'code' in response.authResponse) { + fetch('/api/auth/facebook/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: response.authResponse.code }), + }); + } + }} + onError={(error) => console.error('Login failed:', error)} +> + {({ onClick, loading, isDisabled }) => ( + + )} + +``` diff --git a/apps/docs/content/docs/hooks/use-login.mdx b/apps/docs/content/docs/hooks/use-login.mdx index 2c12946..b672fc3 100644 --- a/apps/docs/content/docs/hooks/use-login.mdx +++ b/apps/docs/content/docs/hooks/use-login.mdx @@ -27,24 +27,50 @@ The hook returns an object with the following properties: ### LoginOptions -The `login` function accepts the following options: +`LoginOptions` is a **discriminated union** — pass either `configId` or `scope`, not both. -| Property | Type | Default | Description | -| -------------- | ---------- | ----------- | ------------------------------------------------------------------------------- | -| `scope` | `string` | `undefined` | Comma-separated list of permissions to request (e.g. `'email,public_profile'`). | -| `returnScopes` | `boolean` | `undefined` | When `true`, the response includes the scopes that were granted. | -| `authType` | `string[]` | `undefined` | Array of auth types to include in the request. | -| `rerequest` | `boolean` | `undefined` | When `true`, asks the user again for previously declined permissions. | -| `reauthorize` | `boolean` | `undefined` | When `true`, forces re-authentication of the user. | +**Shared options** (apply to both flows): + +| Property | Type | Default | Description | +| ------------- | ---------- | ----------- | --------------------------------------------------------------------- | +| `returnScopes` | `boolean` | `undefined` | When `true`, the response includes the scopes that were granted. Applies to the `scope` flow only. | +| `authType` | `string[]` | `undefined` | Array of auth types to include in the request. | +| `rerequest` | `boolean` | `undefined` | When `true`, asks the user again for previously declined permissions. | +| `reauthorize` | `boolean` | `undefined` | When `true`, forces re-authentication of the user. | + +**Scope flow** — pass `scope` to request permissions directly: + +| Property | Type | Default | Description | +| -------- | -------- | ----------- | -------------------------------------------------------------------------------- | +| `scope` | `string` | `undefined` | Comma-separated list of permissions to request (e.g. `'email,public_profile'`).

Note: Mutually exclusive with `configId`. | + +**Business Login flow** — pass `configId` to use a server-defined configuration: + +| Property | Type | Default | Description | +| ---------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------- | +| `configId` | `string` | `undefined` | Facebook Business Login configuration ID. Triggers the BISU code flow (`response_type: 'code'`).

Note: Mutually exclusive with `scope`. | ### LoginResponse -When the status is `'connected'`, the response includes an `authResponse` object with: +When `status` is `'connected'`, the response includes an `authResponse` whose shape depends on the login flow used. + +**Scope flow** (`authResponse` when `scope` was passed): -| Property | Type | Description | -| ------------- | -------- | ------------------------------- | -| `userID` | `string` | The Facebook user ID. | -| `accessToken` | `string` | The access token for API calls. | +| Property | Type | Description | +| ------------- | -------- | ------------------------------------ | +| `userID` | `string` | The Facebook user ID. | +| `accessToken` | `string` | The access token for API calls. | +| `expiresIn` | `number` | Seconds until the token expires. | + +**Business Login flow** (`authResponse` when `configId` was passed): + +| Property | Type | Description | +| ----------- | -------- | ----------------------------------------------------------------------------------------------- | +| `code` | `string` | Short-lived authorization code to exchange server-side for a BISU token. | +| `userID` | `null` | Always `null` — no user-scoped ID is returned in the `configId` flow. | +| `expiresIn` | `number` | `NaN` — expiration is defined by the BISU configuration, not the SDK response. | + +Narrow `authResponse` with `'accessToken' in response.authResponse` before accessing flow-specific fields. ## Usage @@ -99,7 +125,9 @@ function LoginWithErrorHandling() { try { const response = await login({ scope: 'email,public_profile' }); - console.log('Logged in as:', response.authResponse?.userID); + if (response.status === 'connected' && 'accessToken' in response.authResponse) { + console.log('Logged in as:', response.authResponse.userID); + } } catch (err) { const message = err instanceof Error ? err.message : 'An unexpected error occurred'; setLoginError(message); @@ -178,7 +206,7 @@ function LoginAndProfile() { ## Forward Token to Server -After login, send the `accessToken` to your backend API for server-side verification or session creation. +After a scope-based login, send the `accessToken` to your backend for server-side verification or session creation. Narrow `authResponse` with `'accessToken' in response.authResponse` to confirm this is the scope flow before accessing `accessToken` and `userID`. ```tsx import { useLogin } from 'react-facebook'; @@ -191,10 +219,15 @@ function LoginWithBackend() { try { const response = await login({ scope: 'email,public_profile' }); - if (response.status !== 'connected' || !response.authResponse) { + if (response.status !== 'connected') { throw new Error('Login did not complete'); } + // Narrow to scope flow — authResponse contains accessToken, not code + if (!('accessToken' in response.authResponse)) { + throw new Error('Unexpected response type'); + } + const { accessToken, userID } = response.authResponse; // Send the access token to your backend for verification @@ -262,3 +295,42 @@ function ConditionalLogout() { ); } ``` + +## Facebook Login for Business (configId) + +Pass `configId` instead of `scope` to use the [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business) flow. The SDK returns a short-lived authorization **code** in `authResponse.code` — there is no `accessToken`. Exchange this code on your server for a BISU token. + +**Basic usage:** + +```tsx +import { useLogin } from 'react-facebook'; + +function BusinessLoginButton() { + const { login, loading } = useLogin(); + + const handleLogin = async () => { + try { + const response = await login({ configId: 'YOUR_CONFIG_ID' }); + + if (response.status !== 'connected' || !('code' in response.authResponse)) { + throw new Error('Login did not complete'); + } + + // Exchange this code server-side for a BISU token + await fetch('/api/auth/facebook/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: response.authResponse.code }), + }); + } catch (err) { + console.error('Login failed:', err); + } + }; + + return ( + + ); +} +``` diff --git a/apps/docs/content/docs/migration/facebook-login-setup.mdx b/apps/docs/content/docs/migration/facebook-login-setup.mdx index 86c3598..b638019 100644 --- a/apps/docs/content/docs/migration/facebook-login-setup.mdx +++ b/apps/docs/content/docs/migration/facebook-login-setup.mdx @@ -29,7 +29,9 @@ function App() { scope={['public_profile', 'email']} fields={['name', 'email', 'picture']} onSuccess={(response) => { - console.log('Auth token:', response.authResponse.accessToken); + if (response.status === 'connected' && 'accessToken' in response.authResponse) { + console.log('Auth token:', response.authResponse.accessToken); + } }} onProfileSuccess={(profile) => { console.log('User:', profile.name, profile.email); @@ -58,6 +60,8 @@ function LoginButton() { const handleLogin = async () => { try { const response = await login({ scope: 'email,public_profile' }); + // Narrow to the scope flow before accessing accessToken / userID + if (response.status !== 'connected' || !('accessToken' in response.authResponse)) return; // Send token to your backend await fetch('/api/auth/facebook', { method: 'POST', @@ -189,11 +193,21 @@ import { FacebookProvider, FacebookErrorBoundary, Login } from 'react-facebook'; Every component and hook is fully typed. No separate `@types/` package needed. ```tsx -import type { LoginResponse, AuthResponse } from 'react-facebook'; +import type { LoginResponse } from 'react-facebook'; function handleSuccess(response: LoginResponse) { - const token: string = response.authResponse.accessToken; - const userId: string = response.authResponse.userID; + if (response.status !== 'connected') return; + + // Scope flow: authResponse contains accessToken and userID + if ('accessToken' in response.authResponse) { + const token: string = response.authResponse.accessToken; + const userId: string = response.authResponse.userID; + } + + // Business Login (configId) flow: authResponse contains code instead + if ('code' in response.authResponse) { + const code: string = response.authResponse.code; + } } ``` diff --git a/packages/react-facebook/e2e/app/pages/LoginPage.tsx b/packages/react-facebook/e2e/app/pages/LoginPage.tsx index b616f71..a6754d7 100644 --- a/packages/react-facebook/e2e/app/pages/LoginPage.tsx +++ b/packages/react-facebook/e2e/app/pages/LoginPage.tsx @@ -83,6 +83,12 @@ export default function LoginPage() { +
+ {}} data-testid="config-id-login"> + Login with Config ID + +
+ {/* Result display */}
{result}
diff --git a/packages/react-facebook/src/__tests__/Facebook.test.ts b/packages/react-facebook/src/__tests__/Facebook.test.ts index 71c4049..b7b0aff 100644 --- a/packages/react-facebook/src/__tests__/Facebook.test.ts +++ b/packages/react-facebook/src/__tests__/Facebook.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import createFacebook from '../utils/Facebook'; +import createFacebook, { ConfigAuthResponse, ScopeAuthResponse } from '../utils/Facebook'; describe('Facebook', () => { beforeEach(() => { @@ -261,4 +261,312 @@ describe('Facebook', () => { expect(script.src).toContain('/fr_FR/'); }); }); + + describe('login', () => { + type MockFB = { + init: ReturnType; + XFBML: { parse: ReturnType }; + login: ReturnType; + logout: ReturnType; + getLoginStatus: ReturnType; + Event: { subscribe: ReturnType; unsubscribe: ReturnType }; + api: ReturnType; + ui: ReturnType; + }; + + function makeMockFB(loginMock = vi.fn()): MockFB { + return { + init: vi.fn(), + XFBML: { parse: vi.fn() }, + login: loginMock, + logout: vi.fn(), + getLoginStatus: vi.fn(), + Event: { subscribe: vi.fn(), unsubscribe: vi.fn() }, + api: vi.fn(), + ui: vi.fn(), + }; + } + + async function initWithMockedSDK(loginMock = vi.fn()) { + const fb = createFacebook({ appId: 'test123', lazy: true }); + (window as { FB?: unknown }).FB = makeMockFB(loginMock); + const p = fb.init(); + window.fbAsyncInit(); + await p; + return fb; + } + + /** Returns the options object passed as the second argument to FB.login(callback, options). */ + function captureLoginOptions(loginMock: ReturnType): Record { + return loginMock.mock.calls[0][1] as Record; + } + + function resolveLoginWith(loginMock: ReturnType, response: unknown) { + loginMock.mockImplementation((callback: (r: unknown) => void) => callback(response)); + } + + const getConnectedScopeResponse = (overrides?: Partial) => ({ + status: 'connected', + authResponse: { userID: 'user_1', accessToken: 'tok_abc', expiresIn: 3600, ...overrides }, + }); + + const getConnectedConfigResponse = (overrides?: Partial) => ({ + status: 'connected', + authResponse: { userID: null, code: 'auth_code_xyz', expiresIn: NaN, ...overrides }, + }); + + const notAuthorizedResponse = { status: 'not_authorized' }; + + // ── 1. Scope flow ────────────────────────────────────────────────────── + + describe('scope flow', () => { + it('passes scope string to FB.login options', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email,public_profile' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.scope).toBe('email,public_profile'); + }); + + it('does not set config_id, response_type, or override_default_response_type when only scope provided', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.config_id).toBeUndefined(); + expect(opts.response_type).toBeUndefined(); + expect(opts.override_default_response_type).toBeUndefined(); + }); + + it('returns connected response with accessToken', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith( + loginMock, + getConnectedScopeResponse({ accessToken: 'tok_123', userID: 'user_connected_123', expiresIn: 1500 }), + ); + + const response = await fb.login({ scope: 'email' }); + + expect(response.status).toBe('connected'); + if (response.status === 'connected') { + expect(response.authResponse.accessToken).toBe('tok_123'); + expect(response.authResponse.userID).toBe('user_connected_123'); + } + }); + }); + + // ── 2. Without scope ─────────────────────────────────────────────────── + + describe('without scope', () => { + it('calls FB.login with empty options when no scope is provided', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({}); + + const opts = captureLoginOptions(loginMock); + expect(opts.scope).toBeUndefined(); + expect(opts.config_id).toBeUndefined(); + }); + + it('treats scope: undefined the same as omitting scope', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: undefined }); + + const opts = captureLoginOptions(loginMock); + expect(opts.scope).toBeUndefined(); + }); + }); + + // ── 3. Config ID flow ────────────────────────────────────────────────── + + describe('configId flow', () => { + it('sets config_id on FB.login options', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedConfigResponse()); + + await fb.login({ configId: 'cfg_abc123' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.config_id).toBe('cfg_abc123'); + }); + + it('sets response_type to "code"', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedConfigResponse()); + + await fb.login({ configId: 'cfg_abc123' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.response_type).toBe('code'); + }); + + it('sets override_default_response_type to true', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedConfigResponse()); + + await fb.login({ configId: 'cfg_abc123' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.override_default_response_type).toBe(true); + }); + + it('does not set scope', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedConfigResponse()); + + await fb.login({ configId: 'cfg_abc123' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.scope).toBeUndefined(); + }); + + it('returns connected response with code and no accessToken', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedConfigResponse({ code: 'config_auth_code_123' })); + + const response = await fb.login({ configId: 'cfg_abc123' }); + + expect(response.status).toBe('connected'); + if (response.status === 'connected') { + expect(response.authResponse.code).toBe('config_auth_code_123'); + expect(response.authResponse.accessToken).toBeUndefined(); + expect(response.authResponse.userID).toBeNull(); + } + }); + }); + + // ── 4. Additional option flags ───────────────────────────────────────── + + describe('additional option flags', () => { + it('returnScopes: true adds return_scopes to FB.login options', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', returnScopes: true }); + + const opts = captureLoginOptions(loginMock); + expect(opts.return_scopes).toBe(true); + }); + + it('rerequest: true adds auth_type "rerequest"', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', rerequest: true }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBe('rerequest'); + }); + + it('reauthorize: true adds auth_type "reauthenticate"', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', reauthorize: true }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBe('reauthenticate'); + }); + + it('rerequest and reauthorize together produce auth_type "rerequest,reauthenticate"', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', rerequest: true, reauthorize: true }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBe('rerequest,reauthenticate'); + }); + + it('authType array is included in auth_type', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', authType: ['custom_type'] }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBe('custom_type'); + }); + + it('authType array is merged with rerequest boolean option', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email', authType: ['custom_type'], rerequest: true, reauthorize: true }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBe('custom_type,rerequest,reauthenticate'); + }); + + it('no auth_type key when no rerequest/reauthorize/authType provided', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, getConnectedScopeResponse()); + + await fb.login({ scope: 'email' }); + + const opts = captureLoginOptions(loginMock); + expect(opts.auth_type).toBeUndefined(); + }); + }); + + // ── 5. Error cases ───────────────────────────────────────────────────── + + describe('error cases', () => { + it('throws FBError when FB.login returns an error response', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + loginMock.mockImplementation((cb: (r: unknown) => void) => + cb({ error: { code: 190, type: 'OAuthException', message: 'Invalid token' } }), + ); + + await expect(fb.login({ scope: 'email' })).rejects.toMatchObject({ + code: 190, + type: 'OAuthException', + message: '[react-facebook] Invalid token (code: 190, type: OAuthException)', + }); + }); + + it('throws when FB.login callback receives undefined', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + loginMock.mockImplementation((cb: (r: unknown) => void) => cb(undefined)); + + await expect(fb.login({ scope: 'email' })).rejects.toThrow('Response is undefined'); + }); + + it('resolves with non-connected status without throwing', async () => { + const loginMock = vi.fn(); + const fb = await initWithMockedSDK(loginMock); + resolveLoginWith(loginMock, notAuthorizedResponse); + + const response = await fb.login({ scope: 'email' }); + + expect(response.status).toBe('not_authorized'); + }); + }); + }); }); diff --git a/packages/react-facebook/src/components/Login.tsx b/packages/react-facebook/src/components/Login.tsx index 09045bb..7d22c5a 100644 --- a/packages/react-facebook/src/components/Login.tsx +++ b/packages/react-facebook/src/components/Login.tsx @@ -9,8 +9,13 @@ import { } from 'react'; import useLogin from '../hooks/useLogin'; import useProfile from '../hooks/useProfile'; -import type { LoginOptions } from '../hooks/useLogin'; -import type { LoginResponse } from '../utils/Facebook'; +import type { + ConfigLoginOptions, + LoginOptions, + LoginOptionsBase, + LoginResponse, + ScopeLoginOptions, +} from '../utils/Facebook'; type LoginRenderProps = { onClick: () => void; @@ -18,7 +23,7 @@ type LoginRenderProps = { isDisabled: boolean; }; -export type LoginProps = Omit & { +type LoginPropsBase = { children?: ReactNode | ((props: LoginRenderProps) => ReactElement); onSuccess?: (response: LoginResponse) => void; @@ -34,6 +39,14 @@ export type LoginProps = Omit & { style?: CSSProperties; }; +type LoginPropsWithConfigId = LoginPropsBase & ConfigLoginOptions; +type LoginPropsWithScope = LoginPropsBase & + Omit & { + scope?: string | string[]; + }; + +export type LoginProps = LoginPropsWithConfigId | LoginPropsWithScope; + export default function Login(props: LoginProps) { const { children, @@ -44,6 +57,7 @@ export default function Login(props: LoginProps) { as: Component = 'button', disabled = false, scope = ['public_profile', 'email'], + configId, returnScopes, authType, rerequest, @@ -67,14 +81,19 @@ export default function Login(props: LoginProps) { const handleLogin = async () => { if (loading || disabled) return; + const base: LoginOptionsBase = { + returnScopes, + authType, + rerequest, + reauthorize, + }; + + const loginOptions: LoginOptions = configId + ? { ...base, configId } + : { ...base, scope: Array.isArray(scope) ? scope.join(',') : scope }; + try { - const response = await login({ - scope: Array.isArray(scope) ? scope.join(',') : scope, - returnScopes, - authType, - rerequest, - reauthorize, - }); + const response = await login(loginOptions); onSuccess?.(response); } catch (error) { diff --git a/packages/react-facebook/src/hooks/useLogin.ts b/packages/react-facebook/src/hooks/useLogin.ts index bb6210c..0ce8846 100644 --- a/packages/react-facebook/src/hooks/useLogin.ts +++ b/packages/react-facebook/src/hooks/useLogin.ts @@ -1,15 +1,9 @@ import { useState, useCallback, useRef } from 'react'; import useFacebook from './useFacebook'; -import type { LoginResponse } from '../utils/Facebook'; +import type { LoginOptions, LoginResponse } from '../utils/Facebook'; import LoginStatus from '../constants/LoginStatus'; -export type LoginOptions = { - scope?: string; - returnScopes?: boolean; - authType?: string[]; - rerequest?: boolean; - reauthorize?: boolean; -}; +export type { LoginOptions }; export type UseLoginReturn = { login: (loginOptions: LoginOptions, callback?: (response: LoginResponse) => void) => Promise; diff --git a/packages/react-facebook/src/utils/Facebook.ts b/packages/react-facebook/src/utils/Facebook.ts index e5ec683..99cbd8c 100644 --- a/packages/react-facebook/src/utils/Facebook.ts +++ b/packages/react-facebook/src/utils/Facebook.ts @@ -1,11 +1,25 @@ import LoginStatus from '../constants/LoginStatus'; import FBError from '../errors/FBError'; -export type AuthResponse = { +export type ScopeAuthResponse = { userID: string; accessToken: string; + expiresIn: number; + code?: never; }; +export type ConfigAuthResponse = { + /** Authorization code to exchange server-side for a BISU token. */ + code: string; + /** Always null in the config_id flow — no user-scoped ID is returned. */ + userID: null; + /** NaN if token has no expiration time. Time is defined in the BISU configuration */ + expiresIn: number; + accessToken?: never; +}; + +export type AuthResponse = ScopeAuthResponse | ConfigAuthResponse; + export type LoginResponse = | { status: LoginStatus.CONNECTED; @@ -15,14 +29,25 @@ export type LoginResponse = status: Exclude; }; -export type LoginOptions = { - scope?: string; +export type LoginOptionsBase = { returnScopes?: boolean; authType?: string[]; rerequest?: boolean; reauthorize?: boolean; }; +export type ConfigLoginOptions = LoginOptionsBase & { + configId: string; + scope?: never; +}; + +export type ScopeLoginOptions = LoginOptionsBase & { + scope?: string; + configId?: never; +}; + +export type LoginOptions = ConfigLoginOptions | ScopeLoginOptions; + type FBErrorResponse = { error: { code: number; @@ -276,9 +301,18 @@ export default function createFacebook(options: FacebookOptions): FacebookInstan } async function login(loginOpts: LoginOptions) { - const { scope, returnScopes, rerequest, reauthorize } = loginOpts; + const { returnScopes, rerequest, reauthorize, scope, configId } = loginOpts; const types = [...(loginOpts.authType ?? [])]; - const fbLoginOptions: Record = { scope }; + const fbLoginOptions: Record = {}; + + if (scope) fbLoginOptions.scope = scope; + + // https://developers.facebook.com/documentation/facebook-login/facebook-login-for-business#invoking-with-our-sdks + if (configId) { + fbLoginOptions.config_id = configId; + fbLoginOptions.response_type = 'code'; + fbLoginOptions.override_default_response_type = true; + } if (returnScopes) { fbLoginOptions.return_scopes = true;