/reset-passwordReset 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/auth-reset-passwordNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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"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)
}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>
)
}"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>
)
}