Auth & onboarding
Getting in, recovering access and reaching the first useful moment, one decision per screen.
New sign-ups, returning users, people locked out. Shell: focus layout, no navigation. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.
What's included
Pages (registry:page)
Each page is a React component that you mount from a route of your app. It reads the adapter, the routes and the shared state from AuthTemplateProvider.
Blocks (registry:block)
Shared items
auth-types (AuthAdapter, AuthResult, AuthError, AuthProvider, TwoFactorSetup), form-validation (the validation timing for react-hook-form), navigation (LinkComponent, NavLink, RouterLink), class-names, use-countdown, use-focus-on-change, and the mock data: mock-core (seeded randomness) and mock-auth (createMockAuthAdapter, MOCK_AUTH_RULES, mockTwoFactorSetup, mockChecklistTasks). No page or block imports the mock data: install mock-auth only to try the pages before your backend is wired (the onboarding choices — roles, goals, team sizes — are content and ship with the page, in onboarding-steps.ts).
The whole template: auth
@snow-ui-pro/auth is a registry:item without files of its own: its registryDependencies are every page and block above, so one command installs the template (the shared items, hooks and free components come with them). It is not named auth-template: that item is the provider and layout.
Install
Add the two registries to components.json (see installation), then add the whole template:
npx shadcn@latest add @snow-ui-pro/auth
# Optional: the mock backend, to click through the pages before wiring yours.
npx shadcn@latest add @snow-ui-pro/mock-authOr only the pages you need — their blocks, the shared items and the free components come with them:
npx shadcn@latest add @snow-ui-pro/sign-in-page @snow-ui-pro/sign-up-page \
@snow-ui-pro/password-reset-page @snow-ui-pro/verify-email-page \
@snow-ui-pro/two-factor-setup-page @snow-ui-pro/two-factor-challenge-page \
@snow-ui-pro/onboarding-page @snow-ui-pro/getting-started-pageOr a single block: npx shadcn@latest add @snow-ui-pro/otp-input.
npm dependencies the items declare: react-hook-form (forms, through the library's @holakirr/snow-ui/react-hook-form adapter) and @phosphor-icons/react (icons).
Wire it up (Next.js App Router)
The provider holds the adapter, your routes and the in-memory state (the email being verified, the onboarding answers, the checklist), so put it in the layout that wraps the auth routes. It is a client component:
// app/(auth)/auth-provider.tsx
'use client'
import type { Route } from 'next'
import NextLink from 'next/link'
import { useRouter } from 'next/navigation'
import type { ReactNode } from 'react'
import { AuthTemplateProvider } from '@/components/snow-ui-pro/templates/auth/AuthTemplate'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { myAuthAdapter } from '@/lib/auth' // your AuthAdapter
const Link: LinkComponent = ({ href, ...props }) => (
<NextLink href={href as Route} {...props} />
)
export function AuthProvider({ children }: { children: ReactNode }) {
const router = useRouter()
return (
<AuthTemplateProvider
adapter={myAuthAdapter}
navigate={(href) => router.push(href as Route)}
linkAs={Link}
productName="Acme"
routes={{ app: '/home', terms: '/legal/terms' }}
>
{children}
</AuthTemplateProvider>
)
}// app/(auth)/layout.tsx
import { AuthProvider } from './auth-provider'
export default function Layout({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>
}// app/(auth)/sign-in/page.tsx
import { SignInPage } from '@/components/snow-ui-pro/templates/auth/SignInPage'
export default function Page() {
return <SignInPage />
}// app/(auth)/reset-password/page.tsx — the emailed link carries ?token=
import { PasswordResetPage } from '@/components/snow-ui-pro/templates/auth/PasswordResetPage'
import { accountOfResetToken } from '@/lib/auth' // your lookup
type Props = { searchParams: Promise<{ token?: string }> }
export default async function Page({ searchParams }: Props) {
const { token } = await searchParams
// The address lets password managers save the new password for the
// account. Look it up from the token on the server rather than putting
// it in the link (`?email=` lands in logs and browser history).
const email = token ? await accountOfResetToken(token) : undefined
return <PasswordResetPage token={token} email={email} />
}// app/(auth)/onboarding/[step]/page.tsx — the step lives in the URL
import { ONBOARDING_STEPS } from '@/components/snow-ui-pro/templates/auth/onboarding-steps'
import { OnboardingPage } from '@/components/snow-ui-pro/templates/auth/OnboardingPage'
export const dynamicParams = false
export const generateStaticParams = () => ONBOARDING_STEPS.map((step) => ({ step }))
export default async function Page({ params }: { params: Promise<{ step: string }> }) {
return <OnboardingPage step={(await params).step} />
}GettingStartedPage stands in for your app's home (a top bar and the checklist); in your app, render OnboardingChecklist on the home page and its variant="popover" in the shell. Other routers work the same way: pass their navigate and link component.
routes defaults to /sign-in, /sign-up, /reset-password, /verify, /two-factor, /two-factor/setup, /onboarding/[step] and / for the app; override any of them.
The adapter
Everything the pages ask of your backend. Resolve with { ok: false, error } for expected failures (wrong password, taken email, wrong code); reject only when the request failed — the forms show it as a network error with "Try again".
AuthError.code is one of invalid-credentials, locked (retryAfterSeconds), sso-required, email-taken, invalid-code, too-many-attempts, expired, network, unknown; the blocks turn each into a message (override it with message or the block's messages).
Not revealing accounts
The copy never says whether an address has an account, as long as the adapter doesn't either:
- a wrong email and a wrong password are one error,
invalid-credentials("Wrong email or password"); lockedreads "Too many sign-in attempts" — about the attempts, not "your account": return it for unknown addresses too (rate-limit by address and IP);requestResetandsendMagicLinksucceed for any address, and the pages say "If an account exists for …";checkEmail(the sign-up email check on blur) is the one exception, by design: it tells whether an address is registered. Rate-limit it, or leave thecheckEmailprop out ofSignUpFormand answeremail-takenon submit (or email the owner instead of telling the visitor).
The mock rules
createMockAuthAdapter({ latencyMs }) answers after a fixed delay (0 in tests, 400 on the site) by these rules — anything else succeeds:
Customising
- Strings. Every block takes
messages(English defaults; functions where a value goes in, e.g.magicSent: (email) => …). The pages pass the product name; translate by wrapping the pages or the blocks with your messages. - Product.
AuthTemplateProvidertakesproductName(the titles, the brand, the authenticator entry). - Header controls are yours. The template ships no theme or language control:
headerEndonAuthTemplateProviderputs your app's own (a language select, a theme toggle, "Need help?") at the end of every page's header. The site's preview fills it with its RTL and theme switches. - Password rules.
sign-up-formandpassword-resettakepasswordRules({ id, label, test, message }), shown as the checklist and validated on submit. The default rules check length, a small list of common passwords and the name / email — swap the common-password check for a breached-password service in production. - Asides.
AuthLayouttakes anyaside(QuoteAsideandValuesAsideare examples) andasideTheme="dark". - Onboarding. Change the steps in
OnboardingPage(each step is a{ id, title, render, validate }); persist the answers withonAnswersChange/initialAnswerson the provider. - Field states (library 5.2). The email and workspace-address checks use
Input status="progress" | "success": the field draws the ring (witharia-busy) and the green check, and announces them (statusLabel: "Checking the email…", "Valid"). An invalid field gets the library's Warning icon and keeps its grey label;otp-inputfollows the same states for its cells. Submit buttons useButton loading. - QR code. See below.
OAuth providers
defaultAuthProviders are Google and GitHub: "Continue with Google" and "Continue with GitHub", the wording their sign-in button guidelines use, with Phosphor's GoogleLogo and GithubLogo (decorative: the label names the provider). providers is a list of { id, label, icon }; the buttons call adapter.signInWithProvider(id), where you start the provider's OAuth redirect. Pass [] for none, or your own list:
import { AppleLogoIcon } from '@phosphor-icons/react/dist/ssr/AppleLogo'
import { defaultAuthProviders } from '@/components/snow-ui-pro/templates/auth/AuthTemplate'
const providers = [
...defaultAuthProviders,
{
id: 'apple',
label: 'Continue with Apple',
icon: <AppleLogoIcon aria-hidden="true" weight="fill" />,
},
]
<AuthTemplateProvider providers={providers} /* … */ />Word and draw each button as the provider's own guidelines ask (the label, the logo, its size and colours). Google's ask for its standard multicolour "G": the Phosphor mark is monochrome, so swap in Google's official logo where its guidelines apply to you. SignInForm and SignUpForm take the same providers prop when you use the blocks on their own.
The two-factor QR code
TwoFactorSetupPage draws QrPlaceholder: a QR-like pattern derived from the otpauth:// URI, deterministic for server rendering — not a scannable code. Render a real one from TwoFactorSetup.otpauthUri with a QR library, in the browser or on your server; never through a remote QR image service, since the URI carries the account's secret. With qrcode.react (npm install qrcode.react), replace QrPlaceholder in TwoFactorSetupPage.tsx:
import { QRCodeSVG } from 'qrcode.react'
// Was: <QrPlaceholder value={setup.otpauthUri} label={…} />
<ThemeScope theme="light" className="w-fit rounded-12 bg-background-1 p-3">
<QRCodeSVG
value={setup.otpauthUri}
size={160}
marginSize={4} // the quiet zone scanners need
title={`QR code with the key for ${productName}`}
/>
</ThemeScope>Keep it dark on light in both themes (some scanners don't read inverted codes), and keep the key in text beside it for people who can't scan. otpauthUri follows the key-URI format: otpauth://totp/Issuer:account?secret=BASE32&issuer=Issuer&digits=6&period=30.
Accessibility
- One
h1per page; the focus moves to the new heading when a step or state changes, and to the error alert when a request fails (email kept, password cleared). - Validation follows the Pro form rules: a changed field is checked when it loses focus, an invalid one on every change; on submit the first invalid field takes the focus, or the error summary when there are more than three errors.
- Autocomplete:
email,current-password/new-password,name,one-time-code; the reset form carries the account asusernameso password managers save the new password. - Busy buttons use
Button loading: the label stays in place (and in the name) under a spinner, witharia-busyandaria-disabled; the button keeps the focus and ignores clicks, and the block also ignores a second submit while a request runs (Enter twice). Async states (signing in, checking an email, the resend timer at its start and end) are announced in polite live regions or the field's status region. - The one-time-code input has no
maxLength, so a pasted "481 902" keeps all six digits, and a pasted whole code replaces the digits typed so far; after "Resend code" the focus goes back to the code. Codes, keys and emails read left to right in RTL. - Checked in the site's end-to-end tests: every page at 320, 375, 768 and 1280px in the light and dark themes with axe (WCAG 2.2 AA) and without sideways scrolling, and each flow completed with the keyboard only.