Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c71c516
feat(gp): PayrollAdminOnboardingFlow — steps, mutations, and example …
hamzaremote Jun 9, 2026
e0dc5f0
fix(gp): use as const for PayrollAdminOnboardingContext return type
hamzaremote Jun 11, 2026
ccbc8e3
feat(gp): PayrollEmployeeOnboardingFlow (#1078)
hamzaremote Jun 15, 2026
806e982
feat(gp): federal_taxes + state_taxes steps in PayrollEmployeeOnboard…
hamzaremote Jun 15, 2026
e856423
test(gp): smoke tests for PayrollEmployeeOnboarding components
hamzaremote Jun 15, 2026
d81320c
fix(example): restore client_credentials for /countries* GET in proxy
hamzaremote Jun 15, 2026
512d9b8
fix(client): regenerate SDK from production gateway to restore auth
hamzaremote Jun 16, 2026
e3b95f1
review(gp): address reviewer comments on PR #1077
hamzaremote Jun 16, 2026
c1d782f
sec(example): keep all access tokens server-side in the GP demos
hamzaremote Jun 16, 2026
12deb72
fix(gp): render mobile_number as tel widget for non-USA personal_details
hamzaremote Jun 16, 2026
6bcd6d6
fix(gp): pass employment_id when fetching contract/admin schemas
hamzaremote Jun 16, 2026
9235602
chore(ci): fix format drift + raise build heap, sync with main
hamzaremote Jun 16, 2026
8afcea1
fix(client): remove duplicate bank account type exports from index.ts
hamzaremote Jul 6, 2026
5bef538
chore: bump raw bundle size limit to 655 kB for PayrollEmployeeOnboar…
hamzaremote Jul 6, 2026
cb30d66
fix(gp): route errors through handleStepError and fix wizard guard bugs
hamzaremote Jul 6, 2026
1ca1e3a
Revert "fix(gp): route errors through handleStepError and fix wizard …
hamzaremote Jul 7, 2026
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
85 changes: 85 additions & 0 deletions example/api/jwt_auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ async function fetchCompanyManagerToken() {

// Express route handler
async function getCompanyManagerToken(req, res) {
if (process.env.NODE_ENV === 'production') {
return res
.status(403)
.json({ error: 'This endpoint is not available in production mode' });
}
try {
const { accessToken, expiresIn } = await fetchCompanyManagerToken();

Expand All @@ -100,8 +105,88 @@ async function getCompanyManagerToken(req, res) {
}
}

const EMPLOYEE_SCOPES = 'all:write';

async function fetchEmployeeToken(employmentId) {
const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY } =
process.env;

if (
!VITE_CLIENT_ID ||
(!VITE_CLIENT_SECRET && VITE_REMOTE_GATEWAY !== 'local') ||
!VITE_REMOTE_GATEWAY ||
!employmentId
) {
throw new Error(
'Missing VITE_CLIENT_ID, VITE_CLIENT_SECRET, or employmentId',
);
}

const gatewayUrl = buildGatewayURL();
const now = Math.floor(Date.now() / 1000);
const exp = now + 5 * 60;

const payload = {
iss: VITE_CLIENT_ID,
sub: `urn:remote-api:employee:employment:${employmentId}`,
aud: `${gatewayUrl}/auth`,
exp,
scope: EMPLOYEE_SCOPES,
iat: now,
};

const jwtToken = jwt.sign(payload, VITE_CLIENT_SECRET, {
algorithm: 'HS256',
});

const encodedCredentials = Buffer.from(
`${VITE_CLIENT_ID}:${VITE_CLIENT_SECRET}`,
).toString('base64');

const response = await fetch(`${gatewayUrl}/auth/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${encodedCredentials}`,
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwtToken,
scope: EMPLOYEE_SCOPES,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in };
}

async function getEmployeeToken(req, res) {
if (process.env.NODE_ENV === 'production') {
return res
.status(403)
.json({ error: 'This endpoint is not available in production mode' });
}
const { employmentId } = req.params;
try {
const { accessToken, expiresIn } = await fetchEmployeeToken(employmentId);
return res
.status(200)
.json({ access_token: accessToken, expires_in: expiresIn });
} catch (error) {
console.error('Error fetching employee token:', error);
return res.status(500).json({ error: error.message });
}
}

module.exports = {
getCompanyManagerToken,
generateJWTToken,
fetchCompanyManagerToken,
fetchEmployeeToken,
getEmployeeToken,
};
40 changes: 33 additions & 7 deletions example/api/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@ const {
fetchClientCredentialsAccessToken,
fetchAccessToken,
} = require('./get_token.js');
const { fetchEmployeeToken } = require('./jwt_auth.js');
const { buildGatewayURL } = require('./utils.js');

/**
* Determines which token type to use based on HTTP method and path
* @param {string} method - HTTP method (GET, POST, PUT, PATCH, etc.)
* @param {string} path - The API path (e.g., '/v1/countries' or '/v2/countries?foo=bar')
* @returns {'client-credentials' | 'user-token'} The token type to use
* @returns {'client-credentials' | 'user-token' | 'employee-assertion'} The token type to use
*/
function getTokenType(method, path) {
const normalizedMethod = method.toUpperCase();
// Extract pathname without query parameters
const pathname = path.split('?')[0].toLowerCase();

// GET /v1/countries or /v2/countries
// GET /v1/countries or /v2/countries — these don't require a user identity;
// use client_credentials so the call works in CI where no user token is
// available. Local dev without a client secret can opt into a user token
// by setting VITE_REMOTE_GATEWAY=... and ensuring VITE_CLIENT_TOKEN works.
if (normalizedMethod === 'GET' && /^\/v[12]\/countries$/.test(pathname)) {
return 'client-credentials';
}

// GET /v1/countries/{country_code}/address_details or /v2/countries/{country_code}/address_details
// GET /v[12]/countries/{country_code}/address_details — public reference
// data; also use client credentials.
if (
normalizedMethod === 'GET' &&
/^\/v[12]\/countries\/[^/]+\/address_details$/.test(pathname)
Expand Down Expand Up @@ -50,6 +55,13 @@ function getTokenType(method, path) {
return 'client-credentials';
}

// /v1/employee/* endpoints need an employment-scoped assertion. The FE
// identifies which employment via the x-rf-employment-id header; the proxy
// mints the JWT-bearer token server-side so the FE never sees it.
if (/^\/v1\/employee\//.test(pathname)) {
return 'employee-assertion';
}

// All other requests use user token
return 'user-token';
}
Expand Down Expand Up @@ -94,11 +106,25 @@ async function createProxyRequest(path, method = 'GET', options = {}) {
// Add authentication if required
if (requiresAuth) {
const tokenType = getTokenType(method, path);
const { accessToken } =
tokenType === 'client-credentials'
? await fetchClientCredentialsAccessToken()
: await fetchAccessToken();
let accessToken;
if (tokenType === 'client-credentials') {
({ accessToken } = await fetchClientCredentialsAccessToken());
} else if (tokenType === 'employee-assertion') {
const employmentId = headers['x-rf-employment-id'];
if (!employmentId) {
throw Object.assign(
new Error('Missing x-rf-employment-id header for employee request'),
{
response: { status: 400, data: { error: 'employmentId required' } },
},
);
}
({ accessToken } = await fetchEmployeeToken(employmentId));
} else {
({ accessToken } = await fetchAccessToken());
}
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
delete requestConfig.headers['x-rf-employment-id'];
}

return axios(requestConfig);
Expand Down
3 changes: 2 additions & 1 deletion example/api/routes.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
const { getToken } = require('./get_token.js');
const { getCompanyManagerToken } = require('./jwt_auth.js');
const { getCompanyManagerToken, getEmployeeToken } = require('./jwt_auth.js');
const { createProxyMiddleware } = require('./proxy.js');

function setupRoutes(app) {
// API routes
app.get('/api/fetch-refresh-token', getToken);
app.get('/api/fetch-company-manager', getCompanyManagerToken);
app.get('/api/fetch-employee-token/:employmentId', getEmployeeToken);

// Proxy all versioned API routes (v1, v2, etc.)
app.use(/^\/v\d+/, createProxyMiddleware());
Expand Down
16 changes: 16 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { ContractorOnboardingForm } from './ContractorOnboarding';
import { CreateCompanyForm } from './CreateCompany';
import { MagicLinkTest } from './MagicLinkTest';
import { JsonSchemaComparisonDemo } from './JsonSchemaComparisonDemo';
import { PayrollAdminOnboardingForm } from './PayrollAdminOnboarding';
import { PayrollEmployeeOnboardingForm } from './PayrollEmployeeOnboarding';
import ContractorOnboardingCode from './ContractorOnboarding?raw';
import CreateCompanyCode from './CreateCompany?raw';
import MagicLinkTestCode from './MagicLinkTest?raw';
Expand Down Expand Up @@ -175,6 +177,20 @@ const additionalDemos = [
component: JsonSchemaComparisonDemo,
sourceCode: JsonSchemaComparisonCode,
},
{
Comment thread
remotecom marked this conversation as resolved.
id: 'payroll-admin-onboarding',
title: 'GP Admin Onboarding',
description: 'Global Payroll admin onboarding flow',
component: PayrollAdminOnboardingForm,
sourceCode: '',
},
{
id: 'payroll-employee-onboarding',
title: 'GP Employee Onboarding',
description: 'Global Payroll employee self-onboarding flow',
component: PayrollEmployeeOnboardingForm,
sourceCode: '',
},
];

const demoStructure = [
Expand Down
Loading
Loading