Skip to main content

Integration Example

The snippet below is a minimal React host that mounts PrivateKit, captures connectionId, sends UPDATE_USERNAME, and routes the four possible outcomes back to the local form state.

It uses small inline constants for the message-type names. If the host lives in this monorepo you can import { PRIVATE_KIT_MESSAGE_TYPES, USERNAME_VALIDATION_REASON } from 'src/pages/PrivateKitPage/PrivateKitPage' directly. If it lives in another codebase, keep the constants in sync manually (or factor them into a shared package).

import { useEffect, useRef, useState } from 'react';

const TYPES = {
INIT: 'PRIVATE_KIT_INIT',
UPDATE_USERNAME: 'PRIVATE_KIT_UPDATE_USERNAME',
USERNAME_UPDATED: 'PRIVATE_KIT_USERNAME_UPDATED',
USERNAME_VALIDATION_ERROR: 'PRIVATE_KIT_USERNAME_VALIDATION_ERROR',
AUTH_TOKEN_401: 'PRIVATE_KIT_AUTH_TOKEN_401',
} as const;

const PRIVATEKIT_ORIGIN = 'https://auth.example.com';

// Replace these with your own token sources.
declare function getAuthToken(): string;
declare function refreshAuthToken(): Promise<string>;

type Status =
| { kind: 'idle' }
| { kind: 'pending' }
| { kind: 'ok'; username: string }
| { kind: 'error'; message: string }
| { kind: 'token_expired' };

export function PrivateKitChangeUsername() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [connectionId, setConnectionId] = useState<string | null>(null);
const [username, setUsername] = useState('');
const [status, setStatus] = useState<Status>({ kind: 'idle' });

useEffect(() => {
const onMessage = async (e: MessageEvent) => {
if (e.origin !== PRIVATEKIT_ORIGIN) return;
const data = e.data;
if (!data || typeof data !== 'object') return;

if (data.type === TYPES.INIT) {
setConnectionId(data.payload.connectionId);
return;
}

// For every subsequent message validate connectionId
if (!connectionId || data.payload?.connectionId !== connectionId) return;

if (data.type === TYPES.USERNAME_UPDATED) {
setStatus({ kind: 'ok', username: data.payload.username });
return;
}
if (data.type === TYPES.USERNAME_VALIDATION_ERROR) {
const reason: 'required' | 'invalid' | 'exist' | 'unknown' =
data.payload.reason;
const message =
reason === 'exist'
? 'Username is taken'
: (data.payload.message ?? reason);
setStatus({ kind: 'error', message });
return;
}
if (data.type === TYPES.AUTH_TOKEN_401) {
setStatus({ kind: 'token_expired' });
const fresh = await refreshAuthToken();
iframeRef.current?.contentWindow?.postMessage(
{
type: TYPES.UPDATE_USERNAME,
payload: { connectionId, username, authToken: fresh },
},
PRIVATEKIT_ORIGIN,
);
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [connectionId, username]);

const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!connectionId) return;
setStatus({ kind: 'pending' });
iframeRef.current?.contentWindow?.postMessage(
{
type: TYPES.UPDATE_USERNAME,
payload: { connectionId, username, authToken: getAuthToken() },
},
PRIVATEKIT_ORIGIN,
);
};

return (
<>
<iframe
ref={iframeRef}
src={`${PRIVATEKIT_ORIGIN}/private-kit`}
title="private-kit"
style={{ border: 0, width: 0, height: 0 }}
/>
<form onSubmit={submit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="New username"
/>
<button type="submit" disabled={!connectionId}>
Save
</button>
</form>
{status.kind === 'pending' && <p>Saving…</p>}
{status.kind === 'ok' && <p>Saved as {status.username}</p>}
{status.kind === 'error' && <p style={{ color: 'crimson' }}>{status.message}</p>}
{status.kind === 'token_expired' && <p>Refreshing token…</p>}
</>
);
}

A worked-out version of the same component that uses the project's own Form component (instead of a bare <input>) lives at src/pages/DemoPrivateKitTestPage/DemoPrivateKitTestPage.tsx. It exercises the full username and email-change/verification flow end-to-end and is the recommended starting point for adapting the integration to your codebase.

Change email + verification

The email flow is two-step (request change → confirm with code). The shape is the same — connectionId matching, one outbound message per action, single shared AUTH_TOKEN_401 for any 401. Below is the delta vs. the username example.

const EMAIL_TYPES = {
UPDATE_EMAIL: 'PRIVATE_KIT_UPDATE_EMAIL',
EMAIL_UPDATED: 'PRIVATE_KIT_EMAIL_UPDATED',
EMAIL_VALIDATION_ERROR: 'PRIVATE_KIT_EMAIL_VALIDATION_ERROR',
CONFIRM_EMAIL: 'PRIVATE_KIT_CONFIRM_EMAIL',
EMAIL_CONFIRMED: 'PRIVATE_KIT_EMAIL_CONFIRMED',
EMAIL_CONFIRMATION_ERROR: 'PRIVATE_KIT_EMAIL_CONFIRMATION_ERROR',
RESEND_EMAIL_CODE: 'PRIVATE_KIT_RESEND_EMAIL_CODE',
EMAIL_CODE_RESENT: 'PRIVATE_KIT_EMAIL_CODE_RESENT',
} as const;

// Step 1 — request the change
function submitNewEmail(connectionId: string, email: string) {
iframeRef.current?.contentWindow?.postMessage(
{
type: EMAIL_TYPES.UPDATE_EMAIL,
payload: { connectionId, email, authToken: getAuthToken() },
},
PRIVATEKIT_ORIGIN,
);
}

// Step 2 — confirm with the code from the user's inbox
function submitConfirmationCode(connectionId: string, code: string) {
iframeRef.current?.contentWindow?.postMessage(
{
type: EMAIL_TYPES.CONFIRM_EMAIL,
payload: { connectionId, confirmationCode: code, authToken: getAuthToken() },
},
PRIVATEKIT_ORIGIN,
);
}

// Optional — ask the backend to re-send the code
function resendCode(connectionId: string) {
iframeRef.current?.contentWindow?.postMessage(
{
type: EMAIL_TYPES.RESEND_EMAIL_CODE,
payload: { connectionId, authToken: getAuthToken() },
},
PRIVATEKIT_ORIGIN,
);
}

// Inbound dispatch (inside the same `message` listener)
if (data.type === EMAIL_TYPES.EMAIL_UPDATED) {
// Move UI to the verification step. `data.payload.email` is the pending address.
}
if (data.type === EMAIL_TYPES.EMAIL_VALIDATION_ERROR) {
const reason: 'required' | 'invalid' | 'exist' | 'limitReached' | 'unknown' =
data.payload.reason;
// Show inline error on the email input (or as a toast for limitReached/unknown).
}
if (data.type === EMAIL_TYPES.EMAIL_CONFIRMED) {
// The backend rotated the JWTs as part of confirmation. Persist them.
const { email, token, refreshToken } = data.payload;
storeNewTokens(token, refreshToken);
updateProfileEmail(email);
}
if (data.type === EMAIL_TYPES.EMAIL_CONFIRMATION_ERROR) {
const reason: 'required' | 'invalid' | 'invalidCode' | 'unknown' =
data.payload.reason;
// Show inline error on the verification-code input.
}
if (data.type === EMAIL_TYPES.EMAIL_CODE_RESENT) {
// Show a "code resent" toast/hint.
}

The AUTH_TOKEN_401 handler from the username example covers email actions too — on 401, refresh the token and re-send whichever email-action message you last dispatched.

Change phone + verification

The phone flow is structurally identical to the email flow — the same UPDATE_**_UPDATEDCONFIRM_**_CONFIRMED shape, the same RESEND_*_CODE, the same AUTH_TOKEN_401 semantics. Just swap field names: the action payload uses phoneNumber (international format, e.g. "+12025550101") and the success event echoes it back; PHONE_CONFIRMED.payload.phone carries the confirmed number alongside the rotated token / refreshToken.

const PHONE_TYPES = {
UPDATE_PHONE: 'PRIVATE_KIT_UPDATE_PHONE',
PHONE_UPDATED: 'PRIVATE_KIT_PHONE_UPDATED',
PHONE_VALIDATION_ERROR: 'PRIVATE_KIT_PHONE_VALIDATION_ERROR',
CONFIRM_PHONE: 'PRIVATE_KIT_CONFIRM_PHONE',
PHONE_CONFIRMED: 'PRIVATE_KIT_PHONE_CONFIRMED',
PHONE_CONFIRMATION_ERROR: 'PRIVATE_KIT_PHONE_CONFIRMATION_ERROR',
RESEND_PHONE_CODE: 'PRIVATE_KIT_RESEND_PHONE_CODE',
PHONE_CODE_RESENT: 'PRIVATE_KIT_PHONE_CODE_RESENT',
} as const;

function submitNewPhone(connectionId: string, phoneNumber: string) {
iframeRef.current?.contentWindow?.postMessage(
{
type: PHONE_TYPES.UPDATE_PHONE,
payload: { connectionId, phoneNumber, authToken: getAuthToken() },
},
PRIVATEKIT_ORIGIN,
);
}

// CONFIRM_PHONE / RESEND_PHONE_CODE follow the same shape as the email
// equivalents — copy the email handlers and replace EMAIL_* / email / etc.
// with PHONE_* / phoneNumber / phone respectively.

if (data.type === PHONE_TYPES.PHONE_CONFIRMED) {
const { phone, token, refreshToken } = data.payload;
storeNewTokens(token, refreshToken);
updateProfilePhone(phone);
}

Change password

Single-step. The iframe contract is just currentPassword + newPassword — the confirmation field is a host-only concern (match it before posting).

const PASSWORD_TYPES = {
UPDATE_PASSWORD: 'PRIVATE_KIT_UPDATE_PASSWORD',
PASSWORD_UPDATED: 'PRIVATE_KIT_PASSWORD_UPDATED',
PASSWORD_VALIDATION_ERROR: 'PRIVATE_KIT_PASSWORD_VALIDATION_ERROR',
} as const;

function submitNewPassword(
connectionId: string,
currentPassword: string,
newPassword: string,
confirmation: string,
) {
if (newPassword !== confirmation) {
// Host-side check — the iframe does not see the confirmation field.
showFieldError('confirmation', 'Passwords do not match');
return;
}
iframeRef.current?.contentWindow?.postMessage(
{
type: PASSWORD_TYPES.UPDATE_PASSWORD,
payload: {
connectionId,
currentPassword,
newPassword,
authToken: getAuthToken(),
},
},
PRIVATEKIT_ORIGIN,
);
}

if (data.type === PASSWORD_TYPES.PASSWORD_UPDATED) {
// Done. JWTs are still valid — the backend does not rotate them on password change.
}
if (data.type === PASSWORD_TYPES.PASSWORD_VALIDATION_ERROR) {
const reason:
| 'requiredCurrent' | 'requiredNew'
| 'min' | 'uppercase' | 'special' | 'number'
| 'invalidCurrent' | 'unknown' = data.payload.reason;
// Route to currentPassword vs newPassword field based on reason.
}