snow-uiProhome

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.

#ItemComponentSuggested route
U1sign-in-pageSignInPage/sign-in
U2sign-up-pageSignUpPage/sign-up
U3password-reset-pagePasswordResetPage (token from the query, email looked up from it)/reset-password
U4verify-email-pageVerifyEmailPage (email)/verify
U5two-factor-setup-pageTwoFactorSetupPage/two-factor/setup
U5two-factor-challenge-pageTwoFactorChallengePage/two-factor
U6onboarding-pageOnboardingPage (step) + ONBOARDING_STEPS/onboarding/[step]
U7getting-started-pageGettingStartedPage (tasks)your app's home
—auth-templateAuthTemplateProvider, AuthLayout, QuoteAside, ValuesAside, defaultAuthProvidersthe auth routes' layout

Blocks (registry:block)

ItemWhat it is
auth-shellThe focus shell: skip link, brand header, the form column (400 or 480px), footer, and an aside from lg (optionally pinned dark).
sign-in-formEmail and password, OAuth providers, a sign-in link, SSO detection; errors in a focused alert.
sign-up-formName, an email checked on blur, a password with its strength and rules, the terms; an error summary for more than three errors.
password-resetRequest → check your email (resend timer) → new password → done, and the expired-link state.
password-inputThe library Input with a "Show password" toggle (aria-pressed).
password-strengthA four-segment Strip meter (role="meter") and a rule checklist, announced politely at most once a second.
otp-inputOne real input drawn as cells: paste, SMS autofill and screen readers work.
secret-revealRecovery codes or a key, shown once: copy, download (.txt), print, an acknowledgement.
onboarding-wizardSteps with "Step 2 of 5", Back / Skip / Next, validation and focus on the new step; WizardStepList for an aside.
choice-cardsSelectable tiles as a radio or a checkbox group (with a maximum).
onboarding-checklistGetting-started tasks with progress; a panel or the shell's popover button.
file-dropzoneA file input behind "Browse files" with a drop area; type and size checks per file.
invite-dialogInvite by email (token field) with a role and seats; in a Dialog or inline.
form-error-summaryThe errors of a submitted form, each linked to its field.

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:

Terminal
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-auth

Or only the pages you need — their blocks, the shared items and the free components come with them:

Terminal
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-page

Or 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:

tsx
// 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>
  )
}
tsx
// app/(auth)/layout.tsx
import { AuthProvider } from './auth-provider'

export default function Layout({ children }: { children: React.ReactNode }) {
  return <AuthProvider>{children}</AuthProvider>
}
tsx
// app/(auth)/sign-in/page.tsx
import { SignInPage } from '@/components/snow-ui-pro/templates/auth/SignInPage'

export default function Page() {
  return <SignInPage />
}
tsx
// 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} />
}
tsx
// 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".

MethodUsed byResult
signIn({ email, password, remember })U1AuthResult; next: 'two-factor' opens the challenge
signInWithProvider(id), signInWithSso(email), detectSso(email)U1, U2AuthResult; { sso, label }
sendMagicLink(email)U1AuthResult: { ok: true } for any address
signUp({ name, email, password }), checkEmail(email)U2AuthResult (email-taken); { available }
requestReset(email), resetPassword(token, password)U3resolves for any address; AuthResult (expired)
sendVerification(email), verifyEmail(email, code)U4AuthResult (invalid-code, too-many-attempts)
startTwoFactorSetup(), confirmTwoFactor(code)U5 setupTwoFactorSetup; AuthResult
verifyTwoFactor(code, { trustDevice }), redeemRecoveryCode(code)U5 challengeAuthResult (remaining codes)
checkSlug(slug), completeOnboarding(answers)U6{ available }; resolves when the workspace is ready

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");
  • locked reads "Too many sign-in attempts" — about the attempts, not "your account": return it for unknown addresses too (rate-limit by address and IP);
  • requestReset and sendMagicLink succeed 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 the checkEmail prop out of SignUpForm and answer email-taken on 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:

InputResult
password wrong-passwordwrong email or password
locked@example.comlocked for 15 minutes
any @sso.example.com addresssingle sign-on
two-factor@example.comsigns in, then asks for a two-factor code
taken@example.comalready registered
offline@example.comthe request fails (network error)
code 000000wrong code; five wrong codes ask to wait 60 seconds
reset token expiredthe link has expired
workspace address acmetaken

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. AuthTemplateProvider takes productName (the titles, the brand, the authenticator entry).
  • Header controls are yours. The template ships no theme or language control: headerEnd on AuthTemplateProvider puts 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-form and password-reset take passwordRules ({ 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. AuthLayout takes any aside (QuoteAside and ValuesAside are examples) and asideTheme="dark".
  • Onboarding. Change the steps in OnboardingPage (each step is a { id, title, render, validate }); persist the answers with onAnswersChange / initialAnswers on the provider.
  • Field states (library 5.2). The email and workspace-address checks use Input status="progress" | "success": the field draws the ring (with aria-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-input follows the same states for its cells. Submit buttons use Button 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:

tsx
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:

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 h1 per 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 as username so password managers save the new password.
  • Busy buttons use Button loading: the label stays in place (and in the name) under a spinner, with aria-busy and aria-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.