/** * Single source of truth for SDK-managed page prompts and version metadata. * * Consumed by: * - the backend's `/internal/component-versions` endpoint (via * `getLatestPageVersions`), so the dev-tool can tell users when their * installed SDK is outdated. * - the template SDK's `url-targets.ts`, which calls `getCustomPagePrompts` * to build prompt metadata for custom page URL target validation. * * This file lives in stack-shared because both the backend and the template * need the same data, and stack-shared is the only package both can import * from without creating a wrong-direction dependency. */ import { remindersPrompt } from "../ai/unified-prompts/reminders"; import { deindent } from "../utils/strings"; import type { HandlerPageUrls } from "./handler-urls"; export type PageVersionEntry = { minSdkVersion: `${number}.${number}.${number}`, upgradePrompt: string, changelog: string, }; export type PageVersions = Record & { 1: PageVersionEntry }; export type PageComponentKey = Exclude; export type CustomPagePrompt = { title: string, fullPrompt: string, versions: PageVersions, }; function createCustomPagePrompt(options: { key: PageComponentKey, title: string, minSdkVersion: `${number}.${number}.${number}`, structure: string, notes: string, reactExample: string, versions: Omit, }): CustomPagePrompt { const latestPageVersion = Math.max(1, ...Object.keys(options.versions).map(Number)); const latestSdkVersion = latestPageVersion === 1 ? options.minSdkVersion : options.versions[latestPageVersion].minSdkVersion; const fullPrompt = deindent` This prompt explains how to implement a custom ${options.title} page for Hexclave. The version of this page that you are implementing is v${latestPageVersion}. It can be found in Hexclave's documentation, and in the Hexclave devtool indicator. First, make sure to upgrade the Hexclave SDK to a recent version. The minimum supported SDK version for this walkthrough is v${latestSdkVersion}. The user's codebase may already have a ${options.title} page that could be suitable (eg. from an earlier version of Hexclave, a template, another auth provider before migrating to Hexclave, etc.). Use your critical thinking skills to determine what the user's intent is; it is likely that instead of creating a new page, you can just modify the existing page to use Hexclave & support the logic/structure below. Below is a description of the logical structure of what this page should contain (note that the visual structure and layout may be different, and up to you). The page can have more content than this, but it should always contain at least what's described below. ${options.structure} Some more notes: - When implementing the custom page, make sure to adjust its design to match the frameworks, libraries, codestyle, design and branding of the remaining app. ${options.notes} Below is a React example of an extremely minimalistic implementation of this page. Note that this is an example, not a template, and as such you should spend careful consideration on how to implement the page in a way that is consistent with the existing codebase. Also note that these components are NOT self-contained, and NOT shadcn-ui components or a UI framework like that. They serve purely as examples on how to implement the page, but you must make sure to use the correct components and props for the framework and libraries you're using yourself. DO NOT USE THE EXACT DESIGN AS SPECIFIED IN THIS EXAMPLE, INSTEAD MAKE IT LOOK REALLY GOOD. THIS EXAMPLE ONLY DESCRIBES THE MINIMAL LOGIC THAT A SIGN-IN PAGE NEEDS TO SUPPORT, IT IS NOT A COMPLETE EXAMPLE! \`\`\`tsx ${options.reactExample} \`\`\` When you're done, please update the file where the Hexclave app is configured with its URLs, to make sure it points to this page. For example, you may have an object declared like this: \`\`\`tsx export const hexclaveServerApp = new HexclaveServerApp({ tokenStore: "nextjs-cookie", urls: { default: { "type": "hosted", }, } }); \`\`\` You will want to update the \`urls\` property to point to this page, for example: \`\`\`tsx urls: { ${JSON.stringify(options.key)}: { type: "custom", url: "/path/to/your/custom/page", version: ${latestPageVersion} }, // ... }, \`\`\` ${remindersPrompt} `; const versions = { 1: { minSdkVersion: options.minSdkVersion, upgradePrompt: fullPrompt, changelog: "Initial version.", }, ...options.versions, }; return { title: options.title, versions, fullPrompt, }; } type AuthPagePromptType = "signIn" | "signUp"; function createAuthPagePrompt(type: AuthPagePromptType): CustomPagePrompt { const isSignIn = type === "signIn"; const otherType = isSignIn ? "signUp" : "signIn"; const title = isSignIn ? "Sign In" : "Sign Up"; const pageHeading = isSignIn ? "Sign in to your account" : "Create a new account"; const authVerb = isSignIn ? "sign in" : "sign up"; const authVerbCapitalized = isSignIn ? "Sign in" : "Sign up"; const otherAuthVerb = isSignIn ? "sign up" : "sign in"; const credentialMethodCall = isSignIn ? "hexclaveApp.signInWithCredential({ email: form.email, password: form.password })" : "hexclaveApp.signUpWithCredential({ email: form.email, password: form.password })"; const credentialResultType = isSignIn ? "Promise>" : "Promise>"; return createCustomPagePrompt({ key: type, title, minSdkVersion: "0.0.1", structure: deindent` - If user is already signed in, regardless of whether restricted or not (ie. \`await hexclaveApp.getUser({ includeRestricted: true }) !== null\`): - If user is restricted, \`await hexclaveApp.redirectToOnboarding({ replace: true })\` - Otherwise, \`await hexclaveApp.redirectToAfterSign${isSignIn ? "In" : "Up"}({ replace: true })\` - While the redirect is happening, you may display a loading indicator, or a note that the user is being redirected. If necessary, or if preferable, you can also render a message card that shows a link to \`await hexclaveApp.redirectToHome()\` and a sign out button. - If user is not signed in: ${isSignIn ? "- If sign-ups are enabled (\\`project = await hexclaveApp.getProject(); project.config.signUpEnabled\\`), show a link to the sign-up page." : "- If sign-ups are disabled (\\`project = await hexclaveApp.getProject(); !project.config.signUpEnabled\\`), show a message that sign-up is disabled."} - Show a ${authVerb} screen. The auth methods that should render: - For each OAuth provider (\`project.config.oauthProviders: { readonly id: string }[]\`), render an OAuth button. Clicking the button calls \`await hexclaveApp.signInWithOAuth("")\`. ${isSignIn ? "- If \\`project.config.passkeyEnabled\\`, render a passkey button. Clicking the button calls \\`await hexclaveApp.signInWithPasskey()\\`." : ""} - If \`project.config.credentialEnabled\`, render a credential ${authVerb} form: - Email + password${isSignIn ? "" : " + repeat password"} ${isSignIn ? "" : "- Validate password strength with \\`getPasswordError()\\` and ensure repeated password matches"} ${isSignIn ? "- \"Forgot password?\" link calling \\`await hexclaveApp.redirectToForgotPassword()\\`" : ""} - Submit calls \`${credentialMethodCall}: ${credentialResultType}\` - On error, display the error message on the email field - If \`project.config.magicLinkEnabled\`, render a magic link form: - Email input (validated to be a correct email address) + "Send email" button - Calls \`hexclaveApp.sendMagicLinkEmail(email): Promise>\` - After sending, switch to a 6-digit OTP input. User enters the code from their email - Submit the OTP + nonce via \`hexclaveApp.signInWithMagicLink(otp + nonce): Promise>\` (string concatenation) - If both credential and magic-link are enabled, allow the user to choose which flow to use. - If none of the above auth methods are enabled, show a message explaining that no authentication methods are enabled. - Show a link to the ${otherAuthVerb} page that calls \`await hexclaveApp.redirectTo${isSignIn ? "SignUp" : "SignIn"}()\`. `, reactExample: deindent` export default function Custom${isSignIn ? "SignIn" : "SignUp"}Page() { const hexclaveApp = useHexclaveApp(); const user = useUser({ includeRestricted: true }); const project = hexclaveApp.useProject(); const [otpState, setOtpState] = useState(null); useEffect(() => { if (user) { if (user.isRestricted) { void hexclaveApp.redirectToOnboarding(); } else { void hexclaveApp.redirectToAfterSign${isSignIn ? "In" : "Up"}(); } } }, [user]); if (user && !user.isRestricted) { return (
You are already signed in.
); } ${isSignIn ? "" : ` if (!project.config.signUpEnabled) { return Sign-up is not enabled.; }`} if (otpState) { return (
{ const result = await hexclaveApp.signInWithMagicLink(form.otp + otpState.nonce); if (result.status === "error") handleErrorNicely(...); }}> Enter the code from your email Verify code ); } const hasOAuthProviders = project.config.oauthProviders.length > 0; ${isSignIn ? "const hasPasskey = project.config.passkeyEnabled;" : ""} const hasCredential = project.config.credentialEnabled; const hasMagicLink = project.config.magicLinkEnabled; const showSeparator = (hasCredential || hasMagicLink) && ${isSignIn ? "(hasOAuthProviders || hasPasskey)" : "hasOAuthProviders"}; const hasAnyAuthMethod = hasOAuthProviders || hasCredential || hasMagicLink${isSignIn ? " || hasPasskey" : ""}; return (
${pageHeading} ${isSignIn ? `{ project.config.signUpEnabled ? ( {"Don't have an account? "} { e.preventDefault(); await hexclaveApp.redirectToSignUp(); }} > Sign up ) : null }` : ` {"Already have an account? "} { e.preventDefault(); await hexclaveApp.redirectToSignIn(); }} > Sign in `} {${isSignIn ? "(hasOAuthProviders || hasPasskey)" : "hasOAuthProviders"} && (
{project.config.oauthProviders.map((provider) => ( ))} ${isSignIn ? `{hasPasskey && ( )}` : ""}
)} {showSeparator ? ( Or continue with ) : null} {hasCredential || hasMagicLink ? ( {hasMagicLink && Email} {hasCredential && Email & Password} {hasMagicLink &&
{ const result = await hexclaveApp.sendMagicLinkEmail(form.email); if (result.status === "error") handleErrorNicely(...); else setOtpState({ nonce: result.data.nonce }); }}> Send OTP code
} {hasCredential &&
{ ${isSignIn ? "" : `if (form.password !== form.passwordRepeat) { handleErrorNicely(...); return; }`} const result = await ${credentialMethodCall}; if (result.status === "error") handleErrorNicely(...); }}> ${isSignIn ? `` : ` `} ${isSignIn ? "Sign In" : "Sign Up"}
}
) : null} {!hasAnyAuthMethod ? ( No authentication method enabled. ) : null}
); } `, notes: deindent` - This page shares a lot of code with the ${otherType} page, and potentially other pages. Make sure to reuse code and keep behavior consistent wherever possible. `, versions: {}, }); } export function getCustomPagePrompts(): Record { return { signIn: createAuthPagePrompt("signIn"), signUp: createAuthPagePrompt("signUp"), signOut: createCustomPagePrompt({ key: "signOut", title: "Sign Out", minSdkVersion: "0.0.1", structure: deindent` - Read the current user. - If a user exists, sign them out. - After sign-out, show a confirmation state that the user is signed out. `, reactExample: deindent` const cacheSignOut = cacheFunction(async (user: CurrentUser) => { return await user.signOut(); }); export default function CustomSignOutPage() { const user = useUser({ or: "return-null" }); const hexclaveApp = useHexclaveApp(); if (user) { use(cacheSignOut(user)); } return ( { await hexclaveApp.redirectToHome(); }} > You have been signed out successfully. ); } `, notes: deindent` - Keep this page idempotent. Refreshing the page should still leave the user signed out and show a stable confirmation state. `, versions: {}, }), emailVerification: createCustomPagePrompt({ key: "emailVerification", title: "Email Verification", minSdkVersion: "0.0.1", structure: deindent` - Read the verification code from URL params. - If the code is missing, show an invalid-link state. - If the code exists, show a confirmation step: - Verify action calls \`hexclaveApp.verifyEmail(code)\`. - Cancel action calls \`hexclaveApp.redirectToHome()\`. - Handle verification result: - \`VerificationCodeNotFound\` => invalid-link state. - \`VerificationCodeExpired\` => expired-link state. - \`VerificationCodeAlreadyUsed\` => treat as successful verification. - Any other error => throw. - On success, show a verified state with a "Go home" action. `, reactExample: deindent` export default function CustomEmailVerificationPage(props: { searchParams?: Record }) { const hexclaveApp = useHexclaveApp(); const [result, setResult] = useState> | null>(null); const code = props.searchParams?.code; if (!code) { return ; } if (!result) { return ( { setResult(await hexclaveApp.verifyEmail(code)); }} secondaryButtonText="Cancel" secondaryAction={async () => { await hexclaveApp.redirectToHome(); }} /> ); } if (result.status === "error") { if (KnownErrors.VerificationCodeNotFound.isInstance(result.error)) { return ; } else if (KnownErrors.VerificationCodeExpired.isInstance(result.error)) { return ; } else if (!KnownErrors.VerificationCodeAlreadyUsed.isInstance(result.error)) { throw result.error; } } return ( { await hexclaveApp.redirectToHome(); }} /> ); } `, notes: deindent` - Preserve explicit states for invalid, expired, and already-used codes so users know what happened and what to do next. `, versions: {}, }), passwordReset: createCustomPagePrompt({ key: "passwordReset", title: "Password Reset", minSdkVersion: "0.0.1", structure: deindent` - Read the reset code from URL params. - If code is missing, show an invalid-link state. - Before rendering the form, verify the code via \`hexclaveApp.verifyPasswordResetCode(code)\`. - \`VerificationCodeNotFound\` => invalid-link state. - \`VerificationCodeExpired\` => expired-link state. - \`VerificationCodeAlreadyUsed\` => used-link state. - Any other error => throw. - If code is valid, render reset form: - New password + repeated password. - Validate password strength and ensure repeated password matches. - Submit calls \`hexclaveApp.resetPassword({ password, code })\`. - If reset succeeds, show success state. - If reset fails, show error state with guidance to request a new link. `, reactExample: deindent` export default function CustomPasswordResetPage(props: { searchParams: Record }) { const hexclaveApp = useHexclaveApp(); const code = props.searchParams.code; const [password, setPassword] = useState(""); const [passwordRepeat, setPasswordRepeat] = useState(""); const [done, setDone] = useState(false); const [failed, setFailed] = useState(false); const [formError, setFormError] = useState(null); const cachedVerifyPasswordResetCode = cacheFunction(async (app: HexclaveClientApp, codeToVerify: string) => { return await app.verifyPasswordResetCode(codeToVerify); }); if (!code) { return ; } const verificationResult = use(cachedVerifyPasswordResetCode(hexclaveApp, code)); if (verificationResult.status === "error") { if (KnownErrors.VerificationCodeNotFound.isInstance(verificationResult.error)) return ; if (KnownErrors.VerificationCodeExpired.isInstance(verificationResult.error)) return ; if (KnownErrors.VerificationCodeAlreadyUsed.isInstance(verificationResult.error)) return ; throw verificationResult.error; } if (done) return ; if (failed) return ; return (
{ e.preventDefault(); setFormError(null); if (password !== passwordRepeat) { setFormError("Passwords do not match"); return; } const result = await hexclaveApp.resetPassword({ password, code }); if (result.status === "error") setFailed(true); else setDone(true); }}> setPassword(e.target.value)} /> setPasswordRepeat(e.target.value)} /> {formError ? {formError} : null} ); } `, notes: deindent` - Verify the reset code before rendering the form so users immediately get the right state for invalid/expired/used links. `, versions: {}, }), forgotPassword: createCustomPagePrompt({ key: "forgotPassword", title: "Forgot Password", minSdkVersion: "0.0.1", structure: deindent` - If a user is already signed in, show a signed-in state instead of the reset form. - If user is signed out: - Render a forgot-password form with email input. - Submit calls \`hexclaveApp.sendForgotPasswordEmail(email)\`. - On success, switch to an email-sent confirmation state. - Provide a link back to sign-in. `, reactExample: deindent` export default function CustomForgotPasswordPage() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "return-null" }); const [email, setEmail] = useState(""); const [sent, setSent] = useState(false); const [error, setError] = useState(null); if (user) { return ; } if (sent) { return ; } return (
Reset Your Password {"Don't need to reset? "} { e.preventDefault(); await hexclaveApp.redirectToSignIn(); }} > Sign in
{ e.preventDefault(); setError(null); if (!email) { setError("Please enter your email"); return; } await hexclaveApp.sendForgotPasswordEmail(email); setSent(true); }}> setEmail(e.target.value)} /> {error ? {error} : null}
); } `, notes: deindent` - Keep the success state explicit so users know the request succeeded and do not repeatedly re-submit. `, versions: {}, }), oauthCallback: createCustomPagePrompt({ key: "oauthCallback", title: "OAuth Callback", minSdkVersion: "0.0.1", structure: deindent` - Trigger OAuth callback handling once when the page loads by calling \`hexclaveApp.callOAuthCallback()\`. - If callback handler already redirected, keep a neutral loading state. - If callback handler did not redirect, redirect to sign-in with \`hexclaveApp.redirectToSignIn({ noRedirectBack: true })\`. - If callback processing throws, capture/show a useful error state. - Provide a fallback "click here" link in case automatic redirect does not happen. `, reactExample: deindent` export default function CustomOAuthCallbackPage() { const hexclaveApp = useHexclaveApp(); const called = useRef(false); const [error, setError] = useState(null); const [showRedirectLink, setShowRedirectLink] = useState(false); if (!called.current) { called.current = true; void runAsynchronously(async () => { setTimeout(() => setShowRedirectLink(true), 3000); try { const hasRedirected = await hexclaveApp.callOAuthCallback(); if (!hasRedirected) { await hexclaveApp.redirectToSignIn({ noRedirectBack: true }); } } catch (e) { setError(e); } }); } return (
{showRedirectLink ? ( {"If you are not redirected automatically, "} { e.preventDefault(); await hexclaveApp.redirectToHome(); }} > click here ) : null} {error ?
{JSON.stringify(error, null, 2)}
: null}
); } `, notes: deindent` - This page is mainly control flow. Keep user-visible UI minimal while still providing a reliable fallback path. `, versions: {}, }), magicLinkCallback: createCustomPagePrompt({ key: "magicLinkCallback", title: "Magic Link Callback", minSdkVersion: "0.0.1", structure: deindent` - If a user is already signed in, show a signed-in state. - Read the magic-link code from URL params. - If code is missing, show invalid-link state. - If code exists, show a confirmation step: - Confirm action calls \`hexclaveApp.signInWithMagicLink(code)\`. - Cancel action calls \`hexclaveApp.redirectToHome()\`. - Handle callback result: - \`VerificationCodeNotFound\` => invalid-link state. - \`VerificationCodeExpired\` => expired-link state. - \`VerificationCodeAlreadyUsed\` => already-used state. - Any other error => throw. - On success, show a success state with "Go home". `, reactExample: deindent` export default function CustomMagicLinkCallbackPage(props: { searchParams?: Record }) { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "return-null" }); const [result, setResult] = useState> | null>(null); const code = props.searchParams?.code; if (user) return ; if (!code) return ; if (!result) { return ( setResult(await hexclaveApp.signInWithMagicLink(code))} secondaryButtonText="Cancel" secondaryAction={async () => await hexclaveApp.redirectToHome()} /> ); } if (result.status === "error") { if (KnownErrors.VerificationCodeNotFound.isInstance(result.error)) return ; if (KnownErrors.VerificationCodeExpired.isInstance(result.error)) return ; if (KnownErrors.VerificationCodeAlreadyUsed.isInstance(result.error)) return ; throw result.error; } return ( await hexclaveApp.redirectToHome()} /> ); } `, notes: deindent` - Keep invalid/expired/already-used states distinct so users understand whether they should request a new link. `, versions: {}, }), accountSettings: createCustomPagePrompt({ key: "accountSettings", title: "Account Settings", minSdkVersion: "0.0.1", structure: deindent` - Require an authenticated user (\`useUser({ or: "redirect" })\`) and project config (\`hexclaveApp.useProject()\`). - Render top-level pages in this order: - **My Profile** - **Emails & Auth** - **Notifications** - **Active Sessions** - **API Keys** (only if \`project.config.allowUserApiKeys\`) - **Payments** (only if user/team has billable products) - **Settings** - Conditionally include sections: - API keys page only when \`project.config.allowUserApiKeys\` is true. - Payments page only when user has products or at least one team has products. - Render team-related entries: - Show a "Teams" divider when teams exist or team creation is enabled. - For each team in \`user.useTeams()\`, render a team page with these sections: - Team user profile (override your own display name in this team) via \`user.useTeamProfile(team).update(...)\`. - Team profile image (\`team.update({ profileImageUrl })\`) only if \`user.usePermission(team, "$update_team")\`. - Team display name (\`team.update({ displayName })\`) only if \`user.usePermission(team, "$update_team")\`. - Member list (\`team.useUsers()\`) when \`$read_members\` or \`$invite_members\` permission exists. - Invite member form (\`team.inviteUser({ email })\`) when \`$invite_members\`; show outstanding invitations (\`team.useInvitations()\`) and revoke invitation action when \`$remove_members\`. - Team API keys (\`team.useApiKeys()\`, \`team.createApiKey(...)\`) only if \`user.usePermission(team, "$manage_api_keys")\` and \`project.config.allowTeamApiKeys\`. - Leave team confirmation flow using \`user.leaveTeam(team)\`. - Include "Create a team" page when \`project.config.clientTeamCreationEnabled\` and submit via \`user.createTeam({ displayName })\`. - **My Profile** page requirements: - Editable display name (\`user.update({ displayName })\`). - Editable profile image (\`user.update({ profileImageUrl })\`). - **Emails & Auth** page requirements (render all sub-sections in this order): - **Emails**: - List email contact channels from \`user.useContactChannels()\`. - Add email: \`user.createContactChannel({ type: "email", value, usedForAuth: false })\`. - Actions per email (with permission/state guards): send verification email, set primary (only if verified), toggle used-for-sign-in, remove email. - Prevent removing/disabling the last sign-in email. - **Password** (only if \`project.config.credentialEnabled\`): - If user already has password: update flow via \`user.updatePassword({ oldPassword, newPassword })\`. - If user has no password: set flow via \`user.setPassword({ password })\`. - Require a sign-in email before allowing set/update. - Validate password quality via \`getPasswordError()\`. - **Passkey** (only if \`project.config.passkeyEnabled\`): - Register passkey via \`user.registerPasskey()\`. - Disable passkey via \`user.update({ passkeyAuthEnabled: false })\`. - Require a verified sign-in email to enable. - Prevent disabling if passkey is currently the only sign-in method. - **OTP sign-in** (only if \`project.config.magicLinkEnabled\`): - Toggle OTP via \`user.update({ otpAuthEnabled: true | false })\`. - Require a verified sign-in email to enable. - Prevent disabling if OTP is currently the only sign-in method. - **MFA (TOTP)**: - Enable by generating secret + QR code, verify initial code, then persist secret via \`user.update({ totpMultiFactorSecret: secret })\`. - Disable via \`user.update({ totpMultiFactorSecret: null })\`. - **Notifications** page requirements: - Render categories from \`user.useNotificationCategories()\`. - Toggle each category via \`category.setEnabled(value)\`. - Show non-disableable categories as locked. - **Active Sessions** page requirements: - Load sessions via \`user.getActiveSessions()\`. - Show current vs other session, IP, location, created-at/last-used. - Revoke single session via \`user.revokeSession(sessionId)\`. - Revoke all non-current sessions with a confirmation step. - **API Keys** page requirements: - List keys via \`user.useApiKeys()\`. - Create via \`user.createApiKey(options)\`; show first-view key secret once. - Support revoke/update operations from table/actions. - **Payments** page requirements: - Support personal/team customer context switch. - Render current default payment method and allow updating it via setup-intent flow. - Render active plans/products with cancel and switch-plan actions. - Render recent invoices and link to hosted invoice URLs when available. - **Settings** page requirements: - Sign-out section (\`user.signOut()\`). - Delete-account section (only if \`project.config.clientUserDeletionEnabled\`) with destructive confirmation and \`user.delete()\` then redirect home. - Support extension points (for example \`extraItems\`) for custom sections. - Use loading/skeleton states for async sections. `, reactExample: deindent` function ProfileSection() { const user = useUser({ or: "redirect" }); const [displayName, setDisplayName] = useState(user.displayName ?? ""); const [profileImageUrl, setProfileImageUrl] = useState(user.profileImageUrl ?? ""); return (
My Profile setDisplayName(e.target.value)} /> setProfileImageUrl(e.target.value)} />
); } function EmailsSection() { const user = useUser({ or: "redirect" }); const [newEmail, setNewEmail] = useState(""); const contactChannels = user.useContactChannels().filter((x) => x.type === "email"); const usedForAuthCount = contactChannels.filter((x) => x.usedForAuth).length; return (
Emails
{ e.preventDefault(); if (!newEmail) return; await user.createContactChannel({ type: "email", value: newEmail, usedForAuth: false }); setNewEmail(""); }}> setNewEmail(e.target.value)} placeholder="Enter email" />
{contactChannels.map((channel) => { const isLastAuthEmail = channel.usedForAuth && usedForAuthCount === 1; return (
{channel.value}
{!channel.isVerified ? : null} {channel.isVerified && !channel.isPrimary ? : null} {channel.isVerified && !channel.usedForAuth ? : null} {channel.usedForAuth ? : null}
); })}
); } function PasswordSection() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "redirect" }); const project = hexclaveApp.useProject(); const [oldPassword, setOldPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); const hasAuthEmail = user.useContactChannels().some((x) => x.type === "email" && x.usedForAuth); if (!project.config.credentialEnabled) return null; return (
Password {!hasAuthEmail ? To set a password, please add a sign-in email. : null} {user.hasPassword ? setOldPassword(e.target.value)} placeholder="Old password" /> : null} setNewPassword(e.target.value)} placeholder="New password" /> setNewPasswordRepeat(e.target.value)} placeholder="Repeat new password" />
); } function PasskeySection() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "redirect" }); const project = hexclaveApp.useProject(); const hasVerifiedAuthEmail = user.useContactChannels().some((x) => x.type === "email" && x.isVerified && x.usedForAuth); const isOnlyAuthMethod = user.passkeyAuthEnabled && !user.hasPassword && user.oauthProviders.length === 0 && !user.otpAuthEnabled; if (!project.config.passkeyEnabled) return null; return (
Passkey {!hasVerifiedAuthEmail ? Add a verified sign-in email before enabling passkey sign-in. : null} {!user.passkeyAuthEnabled && hasVerifiedAuthEmail ? : null} {user.passkeyAuthEnabled ? ( ) : null}
); } function OtpSection() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "redirect" }); const project = hexclaveApp.useProject(); const hasVerifiedAuthEmail = user.useContactChannels().some((x) => x.type === "email" && x.isVerified && x.usedForAuth); const isOnlyAuthMethod = user.otpAuthEnabled && !user.hasPassword && user.oauthProviders.length === 0 && !user.passkeyAuthEnabled; if (!project.config.magicLinkEnabled) return null; return (
OTP sign-in {!hasVerifiedAuthEmail ? Add a verified sign-in email before enabling OTP sign-in. : null} {!user.otpAuthEnabled && hasVerifiedAuthEmail ? : null} {user.otpAuthEnabled ? : null}
); } function MfaSection() { const user = useUser({ or: "redirect" }); const [generatedSecret, setGeneratedSecret] = useState(null); const [mfaCode, setMfaCode] = useState(""); return (
Multi-factor authentication {!user.isMultiFactorRequired && !generatedSecret ? ( ) : null} {generatedSecret ? (
Show generated QR code here and ask for the first code. setMfaCode(e.target.value)} placeholder="123456" />
) : null} {user.isMultiFactorRequired ? : null}
); } function EmailsAndAuthSection() { return (
Emails & Auth
); } function NotificationsSection() { return (
Notifications Render notification preference controls here.
); } function ActiveSessionsSection() { const user = useUser({ or: "redirect" }); const [sessions, setSessions] = useState([]); return (
Active Sessions {sessions.map((session) => (
{session.isCurrentSession ? "Current Session" : "Other Session"} {session.geoInfo?.ip ?? "-"} / {session.geoInfo?.cityName ?? "Unknown"} {!session.isCurrentSession ? : null}
))}
); } function ApiKeysSection() { const user = useUser({ or: "redirect" }); const [newlyCreated, setNewlyCreated] = useState | null>(null); const apiKeys = user.useApiKeys(); return (
API Keys {newlyCreated ? Copy this key now: {newlyCreated.value} : null} {apiKeys.map((key) => (
{key.description ?? key.id}
))}
); } function PaymentsSection(props: { customer: any, customerType: "user" | "team" }) { const billing = props.customer.useBilling(); const products = props.customer.useProducts().filter((p: any) => p.customerType === props.customerType); const invoices = props.customer.useInvoices({ limit: 10 }); return (
Payments Default payment method: {billing.defaultPaymentMethod ? "set" : "not set"} Active plans {products.map((product: any) => (
{product.displayName} {product.subscription?.isCancelable ? ( ) : null}
))} Invoices {invoices.map((invoice: any, index: number) => (
{invoice.status} {invoice.hostedInvoiceUrl ? View : Unavailable}
))}
); } function TeamSection(props: { team: { displayName: string } }) { const user = useUser({ or: "redirect" }); const hexclaveApp = useHexclaveApp(); const project = hexclaveApp.useProject(); const team = user.useTeam((props.team as any).id); if (!team) return null; const canUpdateTeam = user.usePermission(team, "$update_team"); const canReadMembers = user.usePermission(team, "$read_members"); const canInviteMembers = user.usePermission(team, "$invite_members"); const canRemoveMembers = user.usePermission(team, "$remove_members"); const canManageApiKeys = user.usePermission(team, "$manage_api_keys"); return (
{props.team.displayName} Team user profile {canUpdateTeam ? ( <> Team profile image Team display name ) : null} {(canReadMembers || canInviteMembers) ? ( <> Members {team.useUsers().map((member) => ( {member.teamProfile.displayName ?? "No display name set"} ))} ) : null} {canInviteMembers ? (
Invite member {canReadMembers ? team.useInvitations().map((invitation) => (
{invitation.recipientEmail} {canRemoveMembers ? : null}
)) : null}
) : null} {(canManageApiKeys && project.config.allowTeamApiKeys) ? (
Team API Keys
) : null}
Leave team
); } function CreateTeamSection() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "redirect" }); const project = hexclaveApp.useProject(); const navigate = hexclaveApp.useNavigate(); const [displayName, setDisplayName] = useState(""); if (!project.config.clientTeamCreationEnabled) { return Team creation is not enabled.; } return (
Create a team setDisplayName(e.target.value)} placeholder="Team name" />
); } function SettingsSection() { const user = useUser({ or: "redirect" }); return (
Settings
); } export default function CustomAccountSettingsPage(props: { extraItems?: { id: string, title: string, content: React.ReactNode }[] }) { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "redirect" }); const project = hexclaveApp.useProject(); const teams = user.useTeams(); const [activeId, setActiveId] = useState("profile"); const [selectedPaymentTeamId, setSelectedPaymentTeamId] = useState(null); const [paymentsReady, setPaymentsReady] = useState(false); const [userHasProducts, setUserHasProducts] = useState(false); const [teamIdsWithProducts, setTeamIdsWithProducts] = useState>(new Set()); if (!paymentsReady) { void runAsynchronously(async () => { const userProducts = await user.listProducts({ limit: 1 }); const teamsWithProducts = await Promise.all( teams.map(async (team) => { const isAdmin = await user.hasPermission(team, "team_admin"); if (!isAdmin) return null; const teamProducts = await team.listProducts({ limit: 1 }); const hasTeamProducts = teamProducts.some((product) => product.customerType === "team"); return hasTeamProducts ? team.id : null; }) ); setUserHasProducts(userProducts.some((product) => product.customerType === "user")); setTeamIdsWithProducts(new Set(teamsWithProducts.filter((id): id is string => id !== null))); setPaymentsReady(true); }); } const teamsWithProducts = teams.filter((team) => teamIdsWithProducts.has(team.id)); const shouldShowPayments = paymentsReady && (userHasProducts || teamsWithProducts.length > 0); const selectedPaymentTeam = selectedPaymentTeamId ? teams.find((team) => team.id === selectedPaymentTeamId) ?? null : null; const paymentCustomer = selectedPaymentTeam ?? (userHasProducts ? user : null); const paymentCustomerType = selectedPaymentTeam ? "team" : "user"; const items = [ { id: "profile", title: "My Profile", content: }, { id: "auth", title: "Emails & Auth", content: }, { id: "notifications", title: "Notifications", content: }, { id: "sessions", title: "Active Sessions", content: }, ...(project.config.allowUserApiKeys ? [{ id: "api-keys", title: "API Keys", content: }] : []), ...(shouldShowPayments && paymentCustomer ? [{ id: "payments", title: "Payments", content: (
{teamsWithProducts.length > 0 ? ( ) : null}
), }] : []), ...(props.extraItems ?? []), ...(teams.length > 0 || project.config.clientTeamCreationEnabled ? [{ id: "teams-divider", title: "Teams", content: null }] : []), ...teams.map((team) => ({ id: "team-" + team.id, title: team.displayName, content: })), ...(project.config.clientTeamCreationEnabled ? [{ id: "team-create", title: "Create a team", content: }] : []), { id: "settings", title: "Settings", content: }, ]; const activeItem = items.find((item) => item.id === activeId) ?? items[0]; return (
Account Settings
{items.map((item) => ( ))}
{activeItem.content}
); } `, notes: deindent` - Keep section boundaries explicit and low-coupled so teams can evolve independently without rewriting the full page. `, versions: {}, }), teamInvitation: createCustomPagePrompt({ key: "teamInvitation", title: "Team Invitation", minSdkVersion: "0.0.1", structure: deindent` - Read invitation code from URL params. - If code is missing, show invalid-link state. - Resolve current user with \`includeRestricted: true\`. - If user is signed out, show a sign-in prompt with cancel path. - If user is restricted, route user to onboarding first. - Verify invitation code via \`hexclaveApp.verifyTeamInvitationCode(code)\`: - Not found => invalid-link state. - Expired => expired-link state. - Already used => used-link state. - Other errors => throw. - If code is valid, load invitation details via \`hexclaveApp.getTeamInvitationDetails(code)\`. - Render invitation actions: - Join => \`hexclaveApp.acceptTeamInvitation(code)\`. - Ignore => \`hexclaveApp.redirectToHome()\`. - On successful join, show success state and allow navigation home. `, reactExample: deindent` export default function CustomTeamInvitationPage(props: { searchParams: Record }) { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "return-null", includeRestricted: true }); const code = props.searchParams.code; const [accepted, setAccepted] = useState(false); const [details, setDetails] = useState(null); const [pageError, setPageError] = useState(null); if (!code) return ; if (!user) { return ( await hexclaveApp.redirectToSignIn()} secondaryButtonText="Cancel" secondaryAction={async () => await hexclaveApp.redirectToHome()} /> ); } if (user.isRestricted) { return ( await hexclaveApp.redirectToOnboarding()} /> ); } if (pageError === "invalid") return ; if (pageError === "expired") return ; if (pageError === "used") return ; if (pageError === "unknown") return ; if (!details) { return ( { const verification = await hexclaveApp.verifyTeamInvitationCode(code); if (verification.status === "error") { if (KnownErrors.VerificationCodeNotFound.isInstance(verification.error)) { setPageError("invalid"); return; } if (KnownErrors.VerificationCodeExpired.isInstance(verification.error)) { setPageError("expired"); return; } if (KnownErrors.VerificationCodeAlreadyUsed.isInstance(verification.error)) { setPageError("used"); return; } throw verification.error; } const invitationDetails = await hexclaveApp.getTeamInvitationDetails(code); if (invitationDetails.status === "error") { setPageError("unknown"); return; } setDetails(invitationDetails.data); }} secondaryButtonText="Cancel" secondaryAction={async () => await hexclaveApp.redirectToHome()} > We will verify your invitation before showing the join action. ); } if (accepted) { return You have successfully joined {details.teamDisplayName}; } return ( { const result = await hexclaveApp.acceptTeamInvitation(code); if (result.status === "ok") setAccepted(true); else setPageError("unknown"); }} secondaryButtonText="Ignore" secondaryAction={async () => await hexclaveApp.redirectToHome()} > You are invited to join {details.teamDisplayName} ); } `, notes: deindent` - Treat invitation flow as a gatekeeper: auth state, restricted state, and code validity should be checked in a predictable order. `, versions: {}, }), cliAuthConfirm: createCustomPagePrompt({ key: "cliAuthConfirm", title: "CLI Auth Confirmation", minSdkVersion: "0.0.1", structure: deindent` - Use \`useCliAuthConfirmation()\`. - If \`status === "invalid"\`, show an invalid-link state. - If \`status === "success"\`, tell the user they can close the browser and return to the CLI. - If \`status === "error"\`, show the error and a retry action. - Otherwise, show a confirmation step that calls \`authorize()\`. - Use \`isLoading\` to disable or show loading on the confirmation action while the hook is authorizing or redirecting. `, reactExample: deindent` export default function CustomCliAuthConfirmPage() { const cliAuth = useCliAuthConfirmation(); if (cliAuth.status === "invalid") { return ; } if (cliAuth.status === "success") { return You can close this window and return to the command line.; } if (cliAuth.status === "error") { return ( {cliAuth.error?.message} ); } return ( A command line application is requesting access to your account. ); } `, notes: deindent` - Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application. - The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization. `, versions: {}, }), mfa: createCustomPagePrompt({ key: "mfa", title: "MFA", minSdkVersion: "0.0.1", structure: deindent` - Read the MFA attempt code from session storage. - Render OTP input for the one-time code. - When OTP is complete, submit \`hexclaveApp.signInWithMfa(otp, attemptCode, { noRedirect: true })\`. - Handle result: - Success => clear stored attempt code, show success state, then redirect after sign-in. - \`InvalidTotpCode\` => show invalid-code error and allow retry. - Other errors => show generic verification failure. - Keep a clear verifying/loading state while request is in flight. - Optionally provide a cancel action. `, reactExample: deindent` function OtpInput(props: { value: string, onChange: (value: string) => void, disabled?: boolean }) { return ( props.onChange(value.toUpperCase())} disabled={props.disabled}> {[0, 1, 2, 3, 4, 5].map((index) => ( ))} ); } export default function CustomMfaPage() { const hexclaveApp = useHexclaveApp(); const [otp, setOtp] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [verified, setVerified] = useState(false); const attemptCode = typeof window !== "undefined" // Hexclave rebrand: prefer the new MFA attempt code key, fall back to the legacy key. ? (window.sessionStorage.getItem("hexclave_mfa_attempt_code") ?? window.sessionStorage.getItem("stack_mfa_attempt_code")) : null; const submit = async () => { if (!attemptCode || otp.length !== 6 || submitting) return; setSubmitting(true); setError(null); const result = await hexclaveApp.signInWithMfa(otp, attemptCode, { noRedirect: true }); if (result.status === "ok") { // Hexclave rebrand: remove both the new and legacy MFA attempt code keys. window.sessionStorage.removeItem("hexclave_mfa_attempt_code"); window.sessionStorage.removeItem("stack_mfa_attempt_code"); setVerified(true); await hexclaveApp.redirectToAfterSignIn(); } else if (KnownErrors.InvalidTotpCode.isInstance(result.error)) { setError("Invalid TOTP code"); setOtp(""); } else { setError("Verification failed"); } setSubmitting(false); }; return (
Multi-Factor Authentication Enter the six-digit code from your authenticator app { setOtp(value); if (value.length === 6) { void submit(); } else { setError(null); } }} /> {submitting ? Verifying... : null} {verified ? Verified! Redirecting... : null} {error ? {error} : null}
); } `, notes: deindent` - Keep MFA state transitions explicit (idle, verifying, verified, error) so retries and redirects are predictable. `, versions: {}, }), error: createCustomPagePrompt({ key: "error", title: "Error", minSdkVersion: "0.0.1", structure: deindent` - Read \`errorCode\`, \`message\`, and optional \`details\` from URL params. - If required params are missing, show unknown-error state. - Parse error via \`KnownError.fromJson(...)\`. - If parsing fails, show unknown-error state. - Handle specific known OAuth-related errors with tailored messages/actions. - For all other known errors, show a generic known-error card/state. `, reactExample: deindent` export default function CustomErrorPage(props: { searchParams: Record }) { const hexclaveApp = useHexclaveApp(); const errorCode = props.searchParams.errorCode; const message = props.searchParams.message; const details = props.searchParams.details; if (!errorCode || !message) { return ; } let error: KnownError; try { error = KnownError.fromJson({ code: errorCode, message, details: details ? JSON.parse(details) : {}, }); } catch { return ; } if (KnownErrors.OAuthConnectionAlreadyConnectedToAnotherUser.isInstance(error)) { return hexclaveApp.redirectToHome()} />; } if (KnownErrors.UserAlreadyConnectedToAnotherOAuthConnection.isInstance(error)) { return hexclaveApp.redirectToHome()} />; } if (KnownErrors.OAuthProviderAccessDenied.isInstance(error)) { return ( hexclaveApp.redirectToSignIn()} secondaryButtonText="Go Home" secondaryAction={() => hexclaveApp.redirectToHome()} /> ); } return ; } `, notes: deindent` - Fail safely on malformed query params. Unknown-error fallback should always be available. `, versions: {}, }), onboarding: createCustomPagePrompt({ key: "onboarding", title: "Onboarding", minSdkVersion: "0.0.1", structure: deindent` - Resolve user with \`useUser({ or: "return-null", includeRestricted: true })\`. - Route by user state: - Restricted user resolved to unrestricted => redirect to \`hexclaveApp.redirectToAfterSignIn()\`. - Missing/anonymous user => redirect to \`hexclaveApp.redirectToSignIn()\`. - Restricted user => continue onboarding flow. - Handle restricted reasons: - \`email_not_verified\` and no primary email => ask user for email and call \`user.update({ primaryEmail })\`. - \`email_not_verified\` with primary email => show verification step, resend via \`user.sendVerificationEmail()\`, allow changing email. - Any other restricted reason => show generic setup-required state. - Provide sign-out path from onboarding states. `, reactExample: deindent` export default function CustomOnboardingPage() { const hexclaveApp = useHexclaveApp(); const user = useUser({ or: "return-null", includeRestricted: true }); const [email, setEmail] = useState(""); const [changeEmail, setChangeEmail] = useState(false); if (user && !user.isRestricted) { void runAsynchronously(hexclaveApp.redirectToAfterSignIn()); return null; } if (!user || user.isAnonymous) { void runAsynchronously(hexclaveApp.redirectToSignIn()); return null; } if (user.restrictedReason?.type !== "email_not_verified") { return ( await user.signOut()} /> ); } if (!user.primaryEmail || changeEmail) { return (
{ e.preventDefault(); await user.update({ primaryEmail: email }); setChangeEmail(false); }}> Add your email address setEmail(e.target.value)} />
); } return ( await user.sendVerificationEmail()} secondaryButtonText="Sign out" secondaryAction={async () => await user.signOut()} > Please verify your email address {user.primaryEmail}.{" "} ); } `, notes: deindent` - Treat onboarding as a state machine based on restricted reason; avoid mixing unrelated onboarding states into one branch. `, versions: {}, }), }; } export function getLatestPageVersions(): Record }> { return Object.fromEntries( Object.entries(getCustomPagePrompts()).map(([key, prompt]) => { const versionKeys = Object.keys(prompt.versions).map(Number); const latest = versionKeys.length > 0 ? Math.max(...versionKeys) : 0; const changelogs: Record = {}; for (const v of versionKeys) { if (prompt.versions[v].changelog) { changelogs[v] = prompt.versions[v].changelog; } } return [key, { version: latest, changelogs }]; }) ); }