{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "auth", "type": "registry:block", "title": "Auth block", "description": "Sign-in, two-factor challenge and sign-up over a bring-your-own-auth adapter: the Venly APIs authenticate machines, not people, so these forms render YOUR identity layer. Ships a zero-credential mock adapter with a deterministic 2FA path.", "dependencies": [ "@radix-ui/react-one-time-password-field@^0.1.16" ], "registryDependencies": [ "@venlyfinance/venly-tokens" ], "files": [ { "path": "registry/blocks/auth.tsx", "type": "registry:component", "target": "~/components/venly/blocks/auth.tsx", "content": "import { useState, type CSSProperties, type ReactElement } from \"react\";\nimport * as OneTimePasswordField from \"@radix-ui/react-one-time-password-field\";\n\n/**\n * Auth block – sign-in, two-factor challenge and sign-up over a\n * bring-your-own-auth adapter.\n *\n * Why an adapter and not an API call: the Venly APIs authenticate machines\n * (OAuth2 client credentials), not people. There is no end-user login,\n * session, password or MFA endpoint – by design. End-user auth belongs to\n * YOUR identity layer; these forms render the front of whatever you already\n * run. Real `AuthAdapter` implementations wrap OAuth/OIDC, Better Auth,\n * Auth0, Clerk, Keycloak, or your session cookie – see \"Bring your own\n * auth\" in the package guide. The Venly APIs never see end-user\n * credentials; the sanctioned browser shape is a backend proxy that\n * inherits your app's session.\n *\n * Design contract encoded by this block:\n * - Credential errors never confirm which half was wrong (no user\n * enumeration): one combined message for unknown email and bad password.\n * - The 2FA challenge is a six-digit code field (individual slots, paste\n * distributes across them, fully keyboard-operable) with an explicit\n * \"remember this browser\" opt-in.\n * - Sign-up asks for the minimum (work email + company name) and hands off\n * to onboarding; the organisation record is created there, not here.\n * - The mock adapter never fakes an email send and labels every\n * demo-only affordance as such.\n */\n\n// ── Adapter contract ───────────────────────────────────────────────────\n\nexport type Role = \"ADMIN\" | \"MANAGER\" | \"VIEWER\";\n\nexport interface Session {\n user: { name: string; email: string };\n companyName: string;\n role: Role;\n}\n\nexport interface AuthResult {\n status: \"ok\" | \"2fa-required\" | \"invalid\";\n /** Human-readable reason rendered under the form when status is \"invalid\". */\n message?: string;\n /** Machine-readable reason; \"duplicate-email\" makes sign-up offer sign-in. */\n code?: \"duplicate-email\";\n}\n\n/**\n * The auth boundary these forms render against. Implement it over your\n * identity provider; `createMockAuthAdapter` ships a zero-credential\n * implementation for demos and tests.\n *\n * Session-expiry contract: `session()` returns `null` once the session has\n * expired for any reason. The shell treats null as signed-out and redirects\n * to sign-in – no other expiry signal exists in this interface.\n */\nexport interface AuthAdapter {\n signIn(email: string, password: string, rememberDevice?: boolean): Promise;\n verifyTotp(code: string): Promise;\n signUp(input: { email: string; companyName: string }): Promise;\n session(): Session | null;\n signOut(): Promise;\n}\n\n// ── Mock adapter ───────────────────────────────────────────────────────\n\nexport interface MockAuthAdapter extends AuthAdapter {\n /** Demo driver: drop the current session so the shell's redirect shows. */\n expireSession(): void;\n}\n\nconst DEFAULT_SEEDED_EMAILS = [\"ada@acme.example\", \"casey@acme.example\", \"riley@acme.example\"];\n\nfunction nameFromEmail(email: string): string {\n const local = email.split(\"@\")[0] ?? email;\n return local.charAt(0).toUpperCase() + local.slice(1);\n}\n\n/**\n * Zero-credential mock: any email/password signs in EXCEPT password\n * \"wrong\" (invalid credentials path); an email ending in `@2fa.test`\n * triggers the two-factor challenge, where the deterministic code is\n * `000000`. Sign-up with an already-seeded email returns the duplicate\n * error. Nothing here talks to a network.\n */\nexport function createMockAuthAdapter(options?: {\n seededEmails?: string[];\n companyName?: string;\n}): MockAuthAdapter {\n const seeded = options?.seededEmails ?? DEFAULT_SEEDED_EMAILS;\n const companyName = options?.companyName ?? \"Acme Corporation B.V.\";\n let session: Session | null = null;\n let pending: Session | null = null;\n\n return {\n async signIn(email, password) {\n if (!email || password === \"wrong\") {\n return {\n status: \"invalid\",\n message: \"We don't recognise that email and password combination.\",\n };\n }\n const next: Session = {\n user: { name: nameFromEmail(email), email },\n companyName,\n role: \"ADMIN\",\n };\n if (email.toLowerCase().endsWith(\"@2fa.test\")) {\n pending = next;\n return { status: \"2fa-required\" };\n }\n session = next;\n return { status: \"ok\" };\n },\n async verifyTotp(code) {\n if (code === \"000000\" && pending) {\n session = pending;\n pending = null;\n return { status: \"ok\" };\n }\n return {\n status: \"invalid\",\n message: \"That code doesn't match. Check your authenticator app and try again.\",\n };\n },\n async signUp(input) {\n const email = input.email.trim().toLowerCase();\n if (seeded.some((s) => s.toLowerCase() === email)) {\n return {\n status: \"invalid\",\n code: \"duplicate-email\",\n message: \"That email already has an account.\",\n };\n }\n session = {\n user: { name: nameFromEmail(input.email), email: input.email },\n companyName: input.companyName,\n role: \"ADMIN\",\n };\n return { status: \"ok\" };\n },\n session() {\n return session;\n },\n async signOut() {\n session = null;\n pending = null;\n },\n expireSession() {\n session = null;\n },\n };\n}\n\n// ── Shared form styling (token-driven) ─────────────────────────────────\n\nconst inputStyle: CSSProperties = {\n width: \"100%\",\n boxSizing: \"border-box\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-md)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n};\n\nconst labelStyle: CSSProperties = {\n display: \"block\",\n fontSize: \"var(--font-size-label)\",\n color: \"var(--text-secondary)\",\n marginBottom: \"var(--space-2xs)\",\n};\n\nconst primaryButton: CSSProperties = {\n border: \"none\",\n background: \"var(--accent)\",\n color: \"var(--accent-fg)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n fontWeight: 500,\n cursor: \"pointer\",\n};\n\nconst linkButton: CSSProperties = {\n border: \"none\",\n background: \"transparent\",\n padding: 0,\n fontSize: \"var(--font-size-label)\",\n fontFamily: \"var(--font-family)\",\n color: \"var(--text-secondary)\",\n textDecoration: \"underline\",\n cursor: \"pointer\",\n};\n\nconst cardStyle: CSSProperties = {\n maxWidth: \"var(--auth-form-max-width)\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: \"var(--space-lg)\",\n fontFamily: \"var(--font-family)\",\n};\n\nconst headingStyle: CSSProperties = {\n fontSize: \"var(--font-size-value)\",\n fontWeight: 600,\n color: \"var(--text-primary)\",\n margin: 0,\n};\n\nfunction ErrorText({ children }: { children: string }): ReactElement {\n return (\n \n {children}\n

\n );\n}\n\nfunction DemoNote({ children }: { children: string }): ReactElement {\n return (\n \n {children}\n

\n );\n}\n\n// ── Sign in ────────────────────────────────────────────────────────────\n\nexport interface SignInFormProps {\n adapter: AuthAdapter;\n /** Product name in the heading: \"Sign in to {appName}\". */\n appName: string;\n onSignedIn: () => void;\n onTwoFactorRequired: () => void;\n /** Wire to your provider's reset flow. Absent → an honest demo note. */\n onForgotPassword?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function SignInForm({\n adapter,\n appName,\n onSignedIn,\n onTwoFactorRequired,\n onForgotPassword,\n style,\n className,\n}: SignInFormProps): ReactElement {\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [error, setError] = useState(null);\n const [resetNote, setResetNote] = useState(false);\n const [submitting, setSubmitting] = useState(false);\n\n return (\n {\n e.preventDefault();\n setError(null);\n setSubmitting(true);\n void adapter.signIn(email, password).then((result) => {\n setSubmitting(false);\n if (result.status === \"ok\") onSignedIn();\n else if (result.status === \"2fa-required\") onTwoFactorRequired();\n else setError(result.message ?? \"We don't recognise that email and password combination.\");\n });\n }}\n >\n

Sign in to {appName}

\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n \n setPassword(e.target.value)}\n />\n
\n {error ? {error} : null}\n \n (onForgotPassword ? onForgotPassword() : setResetNote(true))}\n >\n Forgot your password?\n \n {resetNote ? (\n \n Password reset lives with your identity provider – this demo doesn't include one.\n \n ) : null}\n \n );\n}\n\n// ── Two-factor challenge ───────────────────────────────────────────────\n\nexport interface TwoFactorFormProps {\n adapter: AuthAdapter;\n onVerified: () => void;\n /** Wire to your provider's method picker. Absent → an honest demo note. */\n onChooseDifferentMethod?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nconst otpSlotStyle: CSSProperties = {\n width: \"var(--space-2xl)\",\n height: \"var(--space-2xl)\",\n boxSizing: \"content-box\",\n padding: \"var(--space-2xs)\",\n textAlign: \"center\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n fontSize: \"var(--font-size-value)\",\n fontFamily: \"var(--font-family)\",\n fontVariantNumeric: \"tabular-nums\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n};\n\nexport function TwoFactorForm({\n adapter,\n onVerified,\n onChooseDifferentMethod,\n style,\n className,\n}: TwoFactorFormProps): ReactElement {\n const [code, setCode] = useState(\"\");\n const [remember, setRemember] = useState(false);\n const [error, setError] = useState(null);\n const [methodNote, setMethodNote] = useState(false);\n const [submitting, setSubmitting] = useState(false);\n\n return (\n {\n e.preventDefault();\n setError(null);\n setSubmitting(true);\n void adapter.verifyTotp(code).then((result) => {\n setSubmitting(false);\n if (result.status === \"ok\") onVerified();\n else setError(result.message ?? \"That code doesn't match. Check your authenticator app and try again.\");\n });\n }}\n >\n

Two-step verification

\n

\n Enter the 6-digit code from your authenticator app.\n

\n \n {Array.from({ length: 6 }, (_, i) => (\n \n ))}\n \n \n \n setRemember(e.target.checked)}\n />\n Remember this browser for 30 days\n \n {error ? {error} : null}\n \n (onChooseDifferentMethod ? onChooseDifferentMethod() : setMethodNote(true))}\n >\n Choose a different method\n \n {methodNote ? (\n \n An authenticator code is the only method in this demo – real deployments list your\n provider's other methods here.\n \n ) : null}\n \n );\n}\n\n// ── Sign up ────────────────────────────────────────────────────────────\n\nexport interface SignUpFormProps {\n adapter: AuthAdapter;\n /** Product name in the heading: \"Create your {appName} account\". */\n appName: string;\n /** Fires on success; route to onboarding – the organisation is created there. */\n onComplete: () => void;\n /** The \"Sign in instead\" link on the duplicate-email error. */\n onSwitchToSignIn: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function SignUpForm({\n adapter,\n appName,\n onComplete,\n onSwitchToSignIn,\n style,\n className,\n}: SignUpFormProps): ReactElement {\n const [email, setEmail] = useState(\"\");\n const [companyName, setCompanyName] = useState(\"\");\n const [error, setError] = useState(null);\n const [submitting, setSubmitting] = useState(false);\n\n return (\n {\n e.preventDefault();\n setError(null);\n setSubmitting(true);\n void adapter.signUp({ email, companyName }).then((result) => {\n setSubmitting(false);\n if (result.status === \"ok\") onComplete();\n else setError(result);\n });\n }}\n >\n

Create your {appName} account

\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n \n setCompanyName(e.target.value)}\n />\n
\n {error ? (\n error.code === \"duplicate-email\" ? (\n \n That email already has an account.{\" \"}\n \n Sign in instead.\n \n

\n ) : (\n {error.message ?? \"Something went wrong. Try again.\"}\n )\n ) : null}\n \n \n );\n}\n" } ] }