From bc092d06ac6637e243b7fd4e04e4f25a32fcfa7b Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Thu, 24 Sep 2026 16:27:46 -0400 Subject: [PATCH] feat: accept a login box footer in login() `login({ footer })` forwards a structured footer to the native SDKs, which render it below the login box's card on its login screen only: the embedded-mode equivalent of `boxFooter` in @frontegg/react. Like `themeOptions` and `localizations`, an omitted footer is sent as null and clears, so each call fully determines the box's appearance. --- .../frontegg/reactnative/FronteggRNModule.kt | 1 + docs/api.md | 41 ++++++++++++++++++ ios/FronteggRN.swift | 1 + src/FronteggNative.ts | 43 ++++++++++++++++++- src/__tests__/index.test.tsx | 31 +++++++++++-- 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt index 10d09d1..65faa9a 100644 --- a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt +++ b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt @@ -189,6 +189,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : val storage = FronteggInnerStorage() storage.loginBoxThemeOptions = customization?.getMap("themeOptions")?.toHashMap() storage.loginBoxLocalizations = customization?.getMap("localizations")?.toHashMap() + storage.loginBoxFooter = customization?.getMap("footer")?.toHashMap() } @ReactMethod diff --git a/docs/api.md b/docs/api.md index d385386..71678d4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -82,6 +82,7 @@ await login({ | `loginHint` | `string` | Pre-fills the identifier field. | | `themeOptions` | `Record` | Same shape as `themeV2` from `/frontegg/metadata?entityName=adminBox`. Omitted means cleared. | | `localizations` | `Record` | Same shape as `localizations` from the same endpoint. Omitted means cleared. | +| `footer` | `LoginBoxFooter` | Content below the login box's card, on its login screen only. See [Login box footer](#login-box-footer). Omitted means cleared. | Values are deep-merged over the environment's configuration, so keys the override does not mention keep whatever the environment defines. @@ -107,6 +108,46 @@ if (!(await isLoginBoxCustomizationSupported())) { |--------|------------|---------|-------------| | `isLoginBoxCustomizationSupported` | None | `Promise` | Whether this device can apply login box overrides. Always `true` on iOS; `false` on Android WebViews lacking `DOCUMENT_START_SCRIPT`, and on native binaries older than this feature. | +### Login box footer + +The login box's configuration has no slot below the card, which `@frontegg/react` covers with +its `boxFooter` render prop. `footer` is the embedded-mode equivalent, built from a structured +payload rather than markup: + +```ts +await login({ + footer: { + hideCaptchaBadge: true, + rows: [ + { + variant: 'body', + segments: [ + { text: "Don't have an account? " }, + { label: 'Sign up now', url: 'myapp://sign-up' }, + ], + }, + { + variant: 'fine', + segments: [ + { text: 'Protected by reCAPTCHA — ' }, + { label: 'Privacy Policy', url: 'https://policies.google.com/privacy' }, + ], + }, + ], + }, +}); +``` + +- Strings are always rendered as text, never parsed as markup. +- `variant` is `'body'` or `'fine'` (small, de-emphasised legal text). +- Link URLs must be absolute `http(s)` or use one of the app's own registered URL schemes; + anything else renders as plain text. +- `http(s)` links open in the system browser and leave the box in place. A link on the app's + own scheme dismisses the box and hands the URL to the app. +- `hideCaptchaBadge` hides Google's floating reCAPTCHA badge. Google permits this only when the + attribution is shown instead, so set it together with a row naming reCAPTCHA and linking + Google's Privacy Policy and Terms. + ## Step-up authentication | Method | Parameters | Returns | Description | diff --git a/ios/FronteggRN.swift b/ios/FronteggRN.swift index fefe374..f01ddff 100644 --- a/ios/FronteggRN.swift +++ b/ios/FronteggRN.swift @@ -248,6 +248,7 @@ class FronteggRN: RCTEventEmitter { private static func applyLoginBoxCustomization(_ customization: NSDictionary?) { FronteggApp.shared.loginBoxThemeOptions = customization?["themeOptions"] as? [String: Any] FronteggApp.shared.loginBoxLocalizations = customization?["localizations"] as? [String: Any] + FronteggApp.shared.loginBoxFooter = customization?["footer"] as? [String: Any] } /// Maps a FronteggError from login() onto the stable, cross-platform rejection codes diff --git a/src/FronteggNative.ts b/src/FronteggNative.ts index 62c068f..abb0ae1 100644 --- a/src/FronteggNative.ts +++ b/src/FronteggNative.ts @@ -145,6 +145,44 @@ export interface LoginBoxCustomization { * e.g. `{ en: { loginBox: { login: { title: 'Sign-in' } } } }`. */ localizations?: Record | null; + /** + * Content appended below the login box's card, on its login screen only. + * + * The React SDK exposes a `boxFooter` render prop for this; a box served into + * a WebView has no equivalent, and the box's own configuration has no slot + * for content below the card. Structured rather than HTML — strings are + * always rendered as text, never parsed as markup. + * + * Link URLs must be absolute `http(s)` or use one of the app's own + * registered URL schemes; anything else renders as plain text. `http(s)` + * links are handed to the OS rather than loaded in the box, which has no + * navigation chrome — an app-scheme link instead dismisses the box and hands + * off to the app. + */ + footer?: LoginBoxFooter | null; +} + +/** A run of footer content: literal text, or a link. */ +export type LoginBoxFooterSegment = + | { text: string } + | { label: string; url: string }; + +/** One centred line of footer content. */ +export interface LoginBoxFooterRow { + /** `'fine'` is small, de-emphasised legal text. Defaults to `'body'`. */ + variant?: 'body' | 'fine'; + segments: LoginBoxFooterSegment[]; +} + +/** See {@link LoginBoxCustomization.footer}. */ +export interface LoginBoxFooter { + /** + * Hides Google's floating reCAPTCHA badge. Google's terms permit this only + * when the attribution it carries is shown instead, so set it together with + * a row naming reCAPTCHA and linking Google's Privacy Policy and Terms. + */ + hideCaptchaBadge?: boolean; + rows: LoginBoxFooterRow[]; } /** Options accepted by {@link login}. */ @@ -184,9 +222,12 @@ export async function login( const customization = { themeOptions: options.themeOptions ?? null, localizations: options.localizations ?? null, + footer: options.footer ?? null, }; const customized = - customization.themeOptions !== null || customization.localizations !== null; + customization.themeOptions !== null || + customization.localizations !== null || + customization.footer !== null; // FR-25938: previously fire-and-forget (swallowed the result in console.log), so callers could // neither await completion nor observe a cancelled/failed login. Return the promise so it is diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index a5586aa..fef6bff 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -59,7 +59,7 @@ describe('directLoginAction', () => { // Issue #110: login() rejects with a typed, cross-platform FronteggLoginError. describe('login', () => { - const NONE = { themeOptions: null, localizations: null }; + const NONE = { themeOptions: null, localizations: null, footer: null }; beforeEach(() => { (NativeModules.FronteggRN.login as jest.Mock).mockClear(); @@ -102,7 +102,32 @@ describe('login', () => { expect(NativeModules.FronteggRN.loginWithOptions).toHaveBeenCalledWith( 'hint@example.com', - { themeOptions, localizations } + { themeOptions, localizations, footer: null } + ); + }); + + it('forwards a footer', async () => { + const footer = { + hideCaptchaBadge: true, + rows: [ + { + variant: 'fine' as const, + segments: [ + { text: 'Protected by reCAPTCHA — ' }, + { + label: 'Privacy Policy', + url: 'https://policies.google.com/privacy', + }, + ], + }, + ], + }; + + await login({ footer }); + + expect(NativeModules.FronteggRN.loginWithOptions).toHaveBeenCalledWith( + undefined, + { themeOptions: null, localizations: null, footer } ); }); @@ -116,7 +141,7 @@ describe('login', () => { await login({ themeOptions }); expect(NativeModules.FronteggRN.loginWithOptions).toHaveBeenLastCalledWith( undefined, - { themeOptions, localizations: null } + { themeOptions, localizations: null, footer: null } ); await login();