Skip to contentVibraUI
Shared pagesinstalls at /reset-password

Reset password

A centered card for setting a new password from an emailed link: the password twice, a mismatch that marks both fields, and a confirmation that ends on the sign-in page.

Open the live page

The two fields are compared in data.ts, not in the adapter: the confirmation is a property of this form, not of the account. The mismatch names field "confirm" outright — a block-local widening of AuthError, since no backend can tell you that two boxes on a form disagree — so the form never infers a field from an error code. Every message goes where `field` says, and an error that names no field at all (an expired token, a rate limit) falls to the password being set, so it is always visible and always attached to something. The link's token is a hidden input fed by a constant, so the page stays a prop-less server component and renders the same in the gallery as on the route; read it from the query string in your own app. Success ends on a link to sign-in rather than a redirect, because there is no session to carry; focus moves to the confirmation's h2 (tabIndex -1, focused from the mount effect of its own small component) so a reader is not dropped at the top of the document when the form disappears. The page takes one prop, `layout`: "centered" (the default, and what the docs preview and a plain install render) or "split", which sets a brand panel beside the card from lg up. A template picks it; the page forwards it to AuthFrame. Composes AuthFrame (block-local), PasswordInput, Label, and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-reset-password

Needs the @vibra registry in your components.json — set it up once.

Source

app/reset-password/page.tsx
import Link from "next/link"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { ResetPasswordForm } from "./components/reset-password-form"
import { BRAND, RESET_ACCOUNT_EMAIL, SIGN_IN_HREF } from "./data"

/**
 * Set a new password from an emailed link. A server component: only the form
 * is a client island, and the page renders outside AppShell, so the frame owns
 * the single `main`.
 */
export default function ResetPasswordPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Choose a new password"
      description={
        <>
          For <span className="text-foreground">{RESET_ACCOUNT_EMAIL}</span>. This link
          works once, and only for this account.
        </>
      }
      brand={BRAND}
      layout={layout}
      footer={
        <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
          Back to sign in
        </Link>
      }
    >
      <ResetPasswordForm />
    </AuthFrame>
  )
}
app/reset-password/data.ts
/**
 * Everything this page reads. What it changes lives in `actions.ts` beside it,
 * where the adapter runs on the server; this file holds only the types and the
 * vocabulary the page and its client island share.
 */
import { type AuthError } from "@/lib/auth-adapter"

/**
 * The adapter's error plus the one field only this page has. "confirm" is not
 * an account-level concept — no backend can tell you that two boxes on a form
 * disagree — so it is widened here rather than pushed into the shared
 * `AuthError`, and the form reads `field` alone to place every message.
 */
export type ResetError = Omit<AuthError, "field"> & {
  field?: AuthError["field"] | "confirm"
}

export type ResetResult =
  | { ok: true; data: { reset: true } }
  | { ok: false; error: ResetError }

/** The workspace this card is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }

export const SIGN_IN_HREF = "/sign-in"

/**
 * The token a real page reads out of the reset link's query string. It is a
 * constant here so the page stays a prop-less server component and renders the
 * same in the gallery as it does on the route.
 */
export const DEMO_RESET_TOKEN = "rst_8f21c0"

/** Whose password the link is for, shown so a stale link is obvious before it is used. */
export const RESET_ACCOUNT_EMAIL = "ada@northwind.example"

export const MIN_PASSWORD_LENGTH = 8
app/reset-password/actions.ts
"use server"

import { createAuthActions, isFormData, mockAuthAdapter, NOT_A_FORM } from "@/lib/auth-adapter"

import { type ResetResult } from "./data"

/**
 * What this page changes. `createAuthActions` validates the submitted FormData
 * and only then calls the adapter, so pointing the page at a real backend is a
 * one-line change here: hand it your own adapter instead of the mock and
 * nothing above this file moves. The adapter stays on this side of the
 * boundary — its session is module state, and a real one holds a secret.
 */
const actions = createAuthActions(mockAuthAdapter)

function fieldValue(formData: FormData, key: string): string {
  const value = formData.get(key)
  return typeof value === "string" ? value : ""
}

/**
 * Sets a new password from the form's FormData: token, password, confirm.
 *
 * The two fields are compared here rather than in the adapter, because the
 * confirmation is a property of this form and not of the account. The mismatch
 * names `confirm` outright, so the form never has to infer a field from an
 * error code — every message goes wherever `field` says, and anything the
 * adapter leaves unnamed falls to the password being set.
 */
export async function resetPassword(formData: FormData): Promise<ResetResult> {
  if (!isFormData(formData)) return { ok: false, error: NOT_A_FORM }
  const password = fieldValue(formData, "password")
  const confirm = fieldValue(formData, "confirm")

  if (password !== confirm) {
    return {
      ok: false,
      error: { code: "invalid_input", field: "confirm", message: "Passwords do not match." },
    }
  }

  return actions.resetPassword(formData)
}
app/reset-password/components/auth-frame.tsx
import * as React from "react"

export type AuthLayout = "centered" | "split"

export type AuthFrameProps = {
  /** The page's only h1. */
  title: string
  description: React.ReactNode
  brand: { name: string; initial: string }
  /** Sits under the card, outside its frame — the way off this page. */
  footer?: React.ReactNode
  /**
   * Goes in the split panel, under the brand. Decoration inside decoration:
   * the panel is `aria-hidden`, and a "centered" frame has no panel to put it
   * in, so nothing here may be the only place the page says something.
   */
  aside?: React.ReactNode
  /** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
  layout?: AuthLayout
  children: React.ReactNode
}

/**
 * The frame this page sits in. Auth pages render outside AppShell, so nothing
 * else on the route owns a landmark: the frame carries the page's single
 * `main` and its single `h1`.
 *
 * `layout` belongs to the product this page is installed in rather than to the
 * page — a template says which one it wants, and the page forwards it. The
 * card is the same either way; "split" only sets a brand panel beside it, and
 * only from `lg`, where there is room for one. Below that the two layouts are
 * the same screen: a phone gets the card, never a panel stacked above it.
 *
 * Copied into each auth block rather than shared between them. A block
 * installs as a self-contained route, so it brings its own frame with it.
 */
export function AuthFrame({
  title,
  description,
  brand,
  footer,
  aside,
  layout = "centered",
  children,
}: AuthFrameProps) {
  const card = (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <div className="flex items-center justify-center gap-2">
        <span
          aria-hidden="true"
          className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
        >
          {brand.initial}
        </span>
        <span className="text-sm font-medium tracking-tight">{brand.name}</span>
      </div>

      <section className="flex flex-col gap-5 panel p-6">
        <header className="flex flex-col gap-1.5">
          <h1 className="type-display text-3xl text-pretty">{title}</h1>
          <p className="text-sm text-pretty text-muted-foreground">{description}</p>
        </header>
        {children}
      </section>

      {footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
    </div>
  )

  if (layout === "split") {
    return (
      <main
        data-slot="auth-frame"
        data-layout={layout}
        className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
      >
        {/* The panel stays in the accessibility tree: it carries the brand and
            whatever the page hands it through `aside` — sign-up's headline and
            highlights, for one — so only the mark and the hairline are hidden
            as decoration. A surface token rather than the primary colour, so it
            reads as a panel in both themes instead of inverting in the dark one. */}
        <aside
          data-slot="auth-frame-panel"
          className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
        >
          <span
            aria-hidden="true"
            className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
          >
            {brand.initial}
          </span>
          <div className="flex flex-col gap-3">
            <span className="type-display text-3xl">{brand.name}</span>
            <span aria-hidden="true" className="h-px w-16 bg-border" />
          </div>

          {aside}
        </aside>

        <div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
      </main>
    )
  }

  return (
    <main
      data-slot="auth-frame"
      data-layout={layout}
      className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
    >
      {card}
    </main>
  )
}
app/reset-password/components/reset-password-form.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { ShieldCheckIcon } from "lucide-react"

import { Button, buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"

import { resetPassword } from "../actions"
import {
  DEMO_RESET_TOKEN,
  MIN_PASSWORD_LENGTH,
  SIGN_IN_HREF,
  type ResetResult,
} from "../data"

const PASSWORD_ID = "reset-password-new"
const CONFIRM_ID = "reset-password-confirm"
const PASSWORD_HINT_ID = "reset-password-hint"
const MESSAGE_ID = "reset-password-error"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the form,
 * and never on the form's own mount. `tabIndex={-1}` makes it a focus target
 * without putting it in the tab order, so a reader whose focus was on the
 * submit button is moved to what replaced it instead of being dropped at the
 * top of the document.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/**
 * The new-password form, and the confirmation that replaces it. Success ends
 * on a link rather than a redirect: the page has no session to carry, and a
 * reader who got here from an email should be told what happened before being
 * moved somewhere else.
 *
 * Both fields are controlled, because React resets an uncontrolled form once
 * its action settles — and a mismatch that empties both boxes makes the reader
 * type the whole password twice again to fix a typo in one of them.
 */
export function ResetPasswordForm() {
  const [password, setPassword] = React.useState("")
  const [confirm, setConfirm] = React.useState("")
  const [result, formAction, pending] = React.useActionState<ResetResult | null, FormData>(
    (_previous, formData) => resetPassword(formData),
    null
  )

  const error = result && !result.ok ? result.error : null
  const confirmInvalid = error?.field === "confirm"
  // Everything else lands on the password being set — including an error that
  // names no field at all, like an expired token or a rate limit. Those still
  // have to be read, and a message with nowhere to go is a message nobody sees.
  const passwordInvalid = error != null && !confirmInvalid

  // One message, rendered under whichever field owns it, so the reading order
  // and the aria-describedby say the same thing.
  const message = error ? (
    <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
      {error.message}
    </p>
  ) : null

  if (result?.ok) {
    return (
      <div className="flex flex-col gap-5">
        <div className="flex flex-col items-start gap-3">
          <span
            aria-hidden="true"
            className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success"
          >
            <ShieldCheckIcon className="size-4" />
          </span>
          <div className="flex flex-col gap-1.5">
            <ConfirmationHeading>Password updated</ConfirmationHeading>
            <p role="status" className="text-sm text-pretty text-muted-foreground">
              Your new password is in place. Every other session has been signed out.
            </p>
          </div>
        </div>

        <Link href={SIGN_IN_HREF} className={buttonVariants()}>
          Sign in
        </Link>
      </div>
    )
  }

  return (
    <form action={formAction} noValidate className="flex flex-col gap-4">
      <input type="hidden" name="token" value={DEMO_RESET_TOKEN} readOnly />

      <div className="flex flex-col gap-1.5">
        <Label htmlFor={PASSWORD_ID}>New password</Label>
        <PasswordInput
          id={PASSWORD_ID}
          name="password"
          autoComplete="new-password"
          required
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          aria-invalid={passwordInvalid || undefined}
          aria-describedby={
            passwordInvalid ? `${PASSWORD_HINT_ID} ${MESSAGE_ID}` : PASSWORD_HINT_ID
          }
        />
        <p id={PASSWORD_HINT_ID} className="text-xs text-muted-foreground">
          At least {MIN_PASSWORD_LENGTH} characters, and not one you have used elsewhere.
        </p>
        {passwordInvalid ? message : null}
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor={CONFIRM_ID}>Confirm new password</Label>
        <PasswordInput
          id={CONFIRM_ID}
          name="confirm"
          autoComplete="new-password"
          required
          value={confirm}
          onChange={(event) => setConfirm(event.target.value)}
          aria-invalid={confirmInvalid || undefined}
          aria-describedby={confirmInvalid ? MESSAGE_ID : undefined}
        />
        {confirmInvalid ? message : null}
      </div>

      <Button type="submit" disabled={pending}>
        {pending ? "Updating…" : "Update password"}
      </Button>
    </form>
  )
}