Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .reports/embedded-react-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,7 @@ export interface GustoProviderProps {
// Warning: (ae-forgotten-export) The symbol "LoadingIndicatorContextProps" needs to be exported by the entry point index.d.ts
LoaderComponent?: LoadingIndicatorContextProps['LoadingIndicator'];
locale?: string;
nonce?: string;
portalContainer?: HTMLElement;
queryClient?: QueryClient;
theme?: Partial<GustoSDKTheme>;
Expand Down Expand Up @@ -5356,6 +5357,9 @@ export interface UseJobFormReady extends BaseFormHookReady<FieldsMetadata, JobFo
// @public
export type UseJobFormResult = HookLoadingResult | UseJobFormReady;

// @public
export const useNonce: () => string | undefined;

// Warning: (ae-internal-missing-underscore) The name "useObservability" should be prefixed with an underscore because the declaration is marked as @internal
//
// @internal
Expand Down
44 changes: 44 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,47 @@ return (
)
```

## Hooks

<a id="usenonce"></a>

### useNonce()

> **useNonce**(): `string` \| `undefined`

Returns the CSP nonce supplied to [GustoProvider](#gustoprovider), or `undefined` when none was provided.

#### Returns

`string` \| `undefined`

The active nonce, or `undefined` when [GustoProvider](#gustoprovider) was not given a `nonce`.

#### Remarks

Use this when a custom UI component or partner-provided code injects a runtime `<style>` or
`<script>` element and the integrating app serves a nonce-based Content Security Policy.
Apply the returned value as the `nonce` property on the created element (e.g.
`el.nonce = useNonce()`) before appending it to the document.

#### Example

```tsx
import { useNonce } from '@gusto/embedded-react-sdk'

function InjectThemeStyles({ css }: { css: string }) {
const nonce = useNonce()
useEffect(() => {
const el = document.createElement('style')
if (nonce) el.nonce = nonce
el.textContent = css
document.head.appendChild(el)
return () => el.remove()
}, [css, nonce])
return null
}
```

## Functions

<a id="composeerrorhandler"></a>
Expand Down Expand Up @@ -1557,6 +1598,7 @@ you do not supply fall back to the SDK's built-in React Aria implementations.
| <a id="property-gustoapipropslng"></a> `lng?` | `string` | Active i18next language. Defaults to `'en'`. |
| <a id="property-gustoapipropsloadercomponent"></a> `LoaderComponent?` | (`__namedParameters`) => `Element` | Loading indicator rendered while SDK queries are pending. Overrides the SDK default spinner. |
| <a id="property-gustoapipropslocale"></a> `locale?` | `string` | BCP 47 locale used for number, date, and currency formatting throughout the SDK. Defaults to `'en-US'`. |
| <a id="property-gustoapipropsnonce"></a> `nonce?` | `string` | CSP nonce to apply to runtime-injected `<style>` elements (theming, PDF download window). Pass the same per-request nonce your app uses in its `style-src 'nonce-…'` directive. Also exposed to custom UI components via `useNonce`. |
| <a id="property-gustoapipropsportalcontainer"></a> `portalContainer?` | `HTMLElement` | Element to use as the portal container for SDK popovers and dropdowns. Useful when rendering inside a modal or shadow root. |
| <a id="property-gustoapipropsqueryclient"></a> `queryClient?` | `QueryClient` | Optional TanStack Query `QueryClient` to share with the rest of your app. When omitted, the SDK creates its own client configured for Gusto's API. |
| <a id="property-gustoapipropstheme"></a> `theme?` | `Partial`\<[`GustoSDKTheme`](#gustosdktheme)\> | Theme overrides applied to SDK components. See [GustoSDKTheme](#gustosdktheme). |
Expand Down Expand Up @@ -1585,6 +1627,7 @@ Props for [GustoProviderCustomUIAdapter](#gustoprovidercustomuiadapter).
| <a id="property-gustoprovidercustomuiadapterpropslng"></a> `lng?` | `string` | Active i18next language. Defaults to `'en'`. |
| <a id="property-gustoprovidercustomuiadapterpropsloadercomponent"></a> `LoaderComponent?` | (`__namedParameters`) => `Element` | Loading indicator rendered while SDK queries are pending. Overrides the SDK default spinner. |
| <a id="property-gustoprovidercustomuiadapterpropslocale"></a> `locale?` | `string` | BCP 47 locale used for number, date, and currency formatting throughout the SDK. Defaults to `'en-US'`. |
| <a id="property-gustoprovidercustomuiadapterpropsnonce"></a> `nonce?` | `string` | CSP nonce to apply to runtime-injected `<style>` elements (theming, PDF download window). Pass the same per-request nonce your app uses in its `style-src 'nonce-…'` directive. Also exposed to custom UI components via `useNonce`. |
| <a id="property-gustoprovidercustomuiadapterpropsportalcontainer"></a> `portalContainer?` | `HTMLElement` | Element to use as the portal container for SDK popovers and dropdowns. Useful when rendering inside a modal or shadow root. |
| <a id="property-gustoprovidercustomuiadapterpropsqueryclient"></a> `queryClient?` | `QueryClient` | Optional TanStack Query `QueryClient`. When omitted, the SDK creates its own client configured for Gusto's API. |
| <a id="property-gustoprovidercustomuiadapterpropstheme"></a> `theme?` | `Partial`\<[`GustoSDKTheme`](#gustosdktheme)\> | Theme overrides applied to SDK components. See [GustoSDKTheme](#gustosdktheme). |
Expand Down Expand Up @@ -1612,6 +1655,7 @@ Shared configuration props accepted by [GustoProvider](#gustoprovider) and [Gust
| <a id="property-gustoproviderpropslng"></a> `lng?` | `string` | Active i18next language. Defaults to `'en'`. |
| <a id="property-gustoproviderpropsloadercomponent"></a> `LoaderComponent?` | (`__namedParameters`) => `Element` | Loading indicator rendered while SDK queries are pending. Overrides the SDK default spinner. |
| <a id="property-gustoproviderpropslocale"></a> `locale?` | `string` | BCP 47 locale used for number, date, and currency formatting throughout the SDK. Defaults to `'en-US'`. |
| <a id="property-gustoproviderpropsnonce"></a> `nonce?` | `string` | CSP nonce to apply to runtime-injected `<style>` elements (theming, PDF download window). Pass the same per-request nonce your app uses in its `style-src 'nonce-…'` directive. Also exposed to custom UI components via `useNonce`. |
| <a id="property-gustoproviderpropsportalcontainer"></a> `portalContainer?` | `HTMLElement` | Element to use as the portal container for SDK popovers and dropdowns. Useful when rendering inside a modal or shadow root. |
| <a id="property-gustoproviderpropsqueryclient"></a> `queryClient?` | `QueryClient` | Optional TanStack Query `QueryClient`. When omitted, the SDK creates its own client configured for Gusto's API. |
| <a id="property-gustoproviderpropstheme"></a> `theme?` | `Partial`\<[`GustoSDKTheme`](#gustosdktheme)\> | Theme overrides applied to SDK components. See [GustoSDKTheme](#gustosdktheme). |
Expand Down
58 changes: 58 additions & 0 deletions docs/getting-started/proxy-security-partner-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,64 @@ Look up the flows or blocks your app uses, substitute `:param` placeholders with

See the [endpoint reference tables](../reference/endpoint-reference.md) for a human-readable list. Copy the method + path pairs for the components you use and substitute `:param` placeholders with session values at runtime.

## Content Security Policy

The SDK ships a static stylesheet at `@gusto/embedded-react-sdk/style.css` and injects two runtime `<style>` elements at the browser: one for active theme variables, and one inside the new window opened during a paystub PDF download. Both accept a CSP nonce.

### Passing a nonce

Pass the same per-request nonce your app uses for `style-src 'nonce-…'` to `GustoProvider`. The SDK applies it to every `<style>` element it creates.

```tsx
import { GustoProvider } from '@gusto/embedded-react-sdk'

function App({ cspNonce }: { cspNonce: string }) {
return (
<GustoProvider config={{ baseUrl: '/proxy/' }} nonce={cspNonce}>
</GustoProvider>
)
}
```

The same prop is available on `GustoProviderCustomUIAdapter`. If a custom UI component you supply injects its own runtime `<style>` or `<script>`, read the nonce with `useNonce`:

```tsx
import { useEffect } from 'react'
import { useNonce } from '@gusto/embedded-react-sdk'

function InjectedStyles({ css }: { css: string }) {
const nonce = useNonce()
useEffect(() => {
const el = document.createElement('style')
if (nonce) el.nonce = nonce
el.textContent = css
document.head.appendChild(el)
return () => {
el.remove()
}
}, [css, nonce])
return null
}
```

`useNonce` returns `undefined` when no nonce was supplied.

### Minimum policy

```http
Content-Security-Policy:
style-src 'self' 'nonce-XYZ';
style-src-attr 'unsafe-inline';
script-src 'self' 'nonce-XYZ';
img-src 'self' data:;
```

- `style-src 'self' 'nonce-XYZ'` covers the bundled stylesheet and the two runtime `<style>` elements once the nonce is wired through `GustoProvider`.
- `style-src-attr 'unsafe-inline'` is required by inline `style="…"` attributes the SDK uses to apply runtime-computed CSS custom properties (responsive flex and grid layouts, progress-bar fill width, animation timings) and by `react-aria-components` for overlay positioning. The CSP specification does not allow per-attribute nonces, so this directive cannot be tightened further without dropping these features upstream.
- `script-src 'self' 'nonce-XYZ'` — the SDK does not use `eval` or inject `<script>` elements. The nonce is for your own scripts.
- `img-src 'self' data:` is only required if your integration uploads images. The SDK converts uploaded files to `data:` URLs before submitting them.

## FAQ

**Can an authenticated employee access another employee's data?**
Expand Down
4 changes: 4 additions & 0 deletions src/components/Common/Toast/Toast.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.toast {
background-color: rgba(255, 0, 0, 0.2);
color: red;
}
3 changes: 2 additions & 1 deletion src/components/Common/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import styles from './Toast.module.scss'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
//TODO: Style appropriately once design is available
/** @internal */
export function Toast({ message, onClose }: { message: string | null; onClose: () => void }) {
const Components = useComponentContext()
if (!message) return
return (
<div role="alert" style={{ backgroundColor: 'rgba(255,0,0,0.2)', color: 'red' }}>
<div role="alert" className={styles.toast}>
{message}
<Components.Button onClick={onClose}>Close</Components.Button>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Common/UI/Card/Card.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@
background-color: var(--g-colorBody);
box-shadow: var(--g-shadowResting);
}

.cardBody {
flex-grow: 1;
flex-shrink: 1;
}
2 changes: 1 addition & 1 deletion src/components/Common/UI/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function Card({ children, menu, className, action }: CardProps) {
<div className={cn(styles.cardContainer, className)} data-testid="data-card">
<Flex flexDirection="row" gap={8}>
{action && <div>{action}</div>}
<div style={{ flexGrow: 1, flexShrink: 1 }}>
<div className={styles.cardBody}>
<Flex flexDirection={'column'} gap={16}>
{children}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.root {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { useDocumentList } from './useDocumentList'
import styles from './List.module.scss'
import { Flex, DocumentList as SharedDocumentList } from '@/components/Common'

/** @internal */
Expand All @@ -8,7 +9,7 @@ export function List() {
const { t } = useTranslation('Employee.DocumentSigner')

return (
<section style={{ width: '100%' }}>
<section className={styles.root}>
<Flex flexDirection="column" gap={32}>
<SharedDocumentList
forms={employeeForms.map(form => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { setupApiTestMocks } from '@/test/mocks/apiServer'
import { server } from '@/test/mocks/server'
import { API_BASE_URL } from '@/test/constants'
import { componentEvents } from '@/shared/constants'
import { NonceContext } from '@/contexts/NonceProvider'

const stubPayStubs = (
payStubs: Array<Record<string, unknown>>,
Expand Down Expand Up @@ -131,4 +132,51 @@ describe('PaystubsCard', () => {
revokeObjectURLSpy.mockRestore()
}
})

it('applies the CSP nonce from NonceContext to the popup style element', async () => {
stubPayStubs(TWO_STUBS, { 'x-total-count': '2', 'x-total-pages': '1', 'x-page': '1' })
server.use(
http.get(`${API_BASE_URL}/v1/payrolls/:payroll_id/employees/:employee_id/pay_stub`, () =>
HttpResponse.json({ document_url: 'http://example.com/paystub.pdf' }),
),
)

const popupDoc = document.implementation.createHTMLDocument('popup')
const popupWindow = {
document: popupDoc,
addEventListener: vi.fn(),
close: vi.fn(),
location: { href: '' },
} as unknown as Window
const openSpy = vi.spyOn(window, 'open').mockReturnValue(popupWindow)
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-blob-url')
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})

try {
const user = userEvent.setup()
renderWithProviders(
<NonceContext.Provider value="csp-test-nonce">
<PaystubsCard employeeId="employee-123" onEvent={vi.fn()} />
</NonceContext.Provider>,
)

await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Download paystub' })).toHaveLength(2)
})

const [firstDownload] = screen.getAllByRole('button', { name: 'Download paystub' })
await user.click(firstDownload!)

await waitFor(() => {
const popupStyle = popupDoc.head.querySelector<HTMLStyleElement>('style')
expect(popupStyle?.nonce).toBe('csp-test-nonce')
})
} finally {
openSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { DataView, EmptyData, useDataView, Loading } from '@/components/Common'
import { BaseBoundaries, BaseLayout } from '@/components/Base/Base'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { useNonce } from '@/contexts/NonceProvider'
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
import {
usePaymentMethodList,
Expand Down Expand Up @@ -99,6 +100,7 @@ function PaystubsCardReady({
const Components = useComponentContext()
const formatCurrency = useNumberFormatter('currency')
const { showBoundary } = useErrorBoundary()
const nonce = useNonce()

const [downloadingPayrollUuids, setDownloadingPayrollUuids] = useState<ReadonlySet<string>>(
() => new Set(),
Expand Down Expand Up @@ -127,6 +129,7 @@ function PaystubsCardReady({
const doc = newWindow.document
doc.title = loadingMessage
const style = doc.createElement('style')
if (nonce) style.nonce = nonce
style.textContent =
'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' +
'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' +
Expand Down Expand Up @@ -179,7 +182,7 @@ function PaystubsCardReady({
})
}
},
[paystubsList.actions, onEvent, employeeId, t, showBoundary],
[paystubsList.actions, onEvent, employeeId, t, showBoundary, nonce],
)

const payStubsColumns = [
Expand Down
54 changes: 32 additions & 22 deletions src/contexts/GustoProvider/GustoProviderCustomUIAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { sanitizeError } from '../ObservabilityProvider/sanitization'
import { SDKI18next } from './SDKI18next'
import { InternalError } from '@/components/Common'
import { LocaleProvider } from '@/contexts/LocaleProvider'
import { NonceContext } from '@/contexts/NonceProvider'
import { ThemeProvider } from '@/contexts/ThemeProvider'
import type { GustoSDKTheme } from '@/contexts/ThemeProvider/theme'
import type { ResourceDictionary, SupportedLanguages } from '@/types/Helpers'
Expand Down Expand Up @@ -55,6 +56,12 @@ export interface GustoProviderProps {
currency?: string
/** Theme overrides applied to SDK components. See {@link GustoSDKTheme}. */
theme?: Partial<GustoSDKTheme>
/**
* CSP nonce to apply to runtime-injected `<style>` elements (theming, PDF download window).
* Pass the same per-request nonce your app uses in its `style-src 'nonce-…'` directive.
* Also exposed to custom UI components via `useNonce`.
*/
nonce?: string
/** Element to use as the portal container for SDK popovers and dropdowns. Useful when rendering inside a modal or shadow root. */
portalContainer?: HTMLElement
/** Optional TanStack Query `QueryClient`. When omitted, the SDK creates its own client configured for Gusto's API. */
Expand Down Expand Up @@ -97,6 +104,7 @@ const GustoProviderCustomUIAdapter: React.FC<GustoProviderCustomUIAdapterProps>
locale = 'en-US',
currency = 'USD',
theme,
nonce,
portalContainer,
components,
LoaderComponent,
Expand Down Expand Up @@ -144,28 +152,30 @@ const GustoProviderCustomUIAdapter: React.FC<GustoProviderCustomUIAdapterProps>
}
}, [config.observability])
return (
<ComponentsProvider value={components}>
<LoadingIndicatorProvider value={LoaderComponent}>
<ObservabilityProvider observability={config.observability}>
<ErrorBoundary FallbackComponent={InternalError} onError={handleTopLevelError}>
<ThemeProvider theme={theme} portalContainer={portalContainer}>
<LocaleProvider locale={locale} currency={currency}>
<I18nextProvider i18n={SDKI18next} key={lng}>
<ApiProvider
url={config.baseUrl}
headers={config.headers}
hooks={config.hooks}
queryClient={queryClient}
>
{children}
</ApiProvider>
</I18nextProvider>
</LocaleProvider>
</ThemeProvider>
</ErrorBoundary>
</ObservabilityProvider>
</LoadingIndicatorProvider>
</ComponentsProvider>
<NonceContext.Provider value={nonce}>
<ComponentsProvider value={components}>
<LoadingIndicatorProvider value={LoaderComponent}>
<ObservabilityProvider observability={config.observability}>
<ErrorBoundary FallbackComponent={InternalError} onError={handleTopLevelError}>
<ThemeProvider theme={theme} portalContainer={portalContainer}>
<LocaleProvider locale={locale} currency={currency}>
<I18nextProvider i18n={SDKI18next} key={lng}>
<ApiProvider
url={config.baseUrl}
headers={config.headers}
hooks={config.hooks}
queryClient={queryClient}
>
{children}
</ApiProvider>
</I18nextProvider>
</LocaleProvider>
</ThemeProvider>
</ErrorBoundary>
</ObservabilityProvider>
</LoadingIndicatorProvider>
</ComponentsProvider>
</NonceContext.Provider>
)
}

Expand Down
1 change: 1 addition & 0 deletions src/contexts/NonceProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NonceContext, useNonce } from './useNonce'
Loading
Loading