Sign-in forms
The way in: email and password, a magic link, single sign-on and passkeys.
Email and password beside what is new
sign-in-forms-01The credentials form — a reveal toggle, the way to reset a password, a choice to stay signed in and the outside providers — beside the releases a returning reader has missed.
import * as React from "react"
import Link from "next/link"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PasswordSignIn } from "./sign-in-forms-01.client"
import { DEMO_NOTE, LINKS, continueWith, signInWithPassword, whatsNew } from "./sign-in-forms-01.data"
/**
* Signing in with an address and a password, in one framed card: the form on
* one side — with the reveal toggle, the way to reset a forgotten password,
* a choice to stay signed in and the outside providers under it — and on the
* other what has shipped since a returning reader was last here.
*
* A server component. It reads the releases through `./sign-in-forms-01.data`
* and hands the island two server functions defined here: `signIn`, which
* checks the form and asks the auth adapter, and `start`, which starts a
* sign-in with an outside provider. The adapter never leaves the server.
*/
export function SignInForms01() {
const titleId = React.useId()
const notes = whatsNew()
async function signIn(formData: FormData) {
"use server"
return signInWithPassword(formData)
}
async function start(provider: string) {
"use server"
return continueWith(provider)
}
return (
<section
data-section="sign-in-forms-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sign in to Northwind</h2>
</CardTitle>
<CardDescription>Use your work email. Welcome back.</CardDescription>
</CardHeader>
<CardContent className="grid p-0 lg:grid-cols-2">
<div className="p-(--card-spacing) md:p-8">
<div className="mx-auto w-full max-w-sm">
<PasswordSignIn links={LINKS} demoNote={DEMO_NOTE} signIn={signIn} continueWith={start} />
</div>
</div>
<aside
aria-labelledby={`${titleId}-news`}
className="flex flex-col gap-5 border-t bg-surface p-(--card-spacing) md:p-8 lg:border-s lg:border-t-0"
>
<h3 id={`${titleId}-news`} className="text-sm font-semibold">
New since you were last here
</h3>
<ol className="flex flex-col gap-5">
{notes.map((note) => (
<li key={note.version} className="flex flex-col gap-1">
<p className="flex flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
<span className="font-mono text-foreground">{note.version}</span>
<time dateTime={note.iso} className="tabular-nums">
{note.date}
</time>
</p>
<p className="text-sm font-medium text-balance">{note.title}</p>
<p className="text-sm text-pretty text-muted-foreground">{note.summary}</p>
</li>
))}
</ol>
<Link
href={LINKS.changelog}
className="mt-auto w-fit text-sm text-foreground underline underline-offset-4"
>
Every release note
</Link>
</aside>
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms01"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"
import { SocialAuthButtons, type SocialAuthProvider } from "@/components/ui/social-auth-buttons"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult } from "./sign-in-forms-01.data"
type FieldName = "email" | "password"
export type PasswordSignInProps = {
links: { forgot: string; signUp: string; next: string }
demoNote: string
signIn: (formData: FormData) => Promise<AuthResult>
continueWith: (provider: SocialAuthProvider) => Promise<AuthResult<{ provider: string }>>
}
/**
* The credentials form: an address, a password with its reveal toggle and
* the way to reset it, and a choice to stay signed in; under it the outside
* providers. The form checks what it can before anything is sent — an
* address in the shape of one, a password at all — and the adapter decides
* the rest on the server. A refusal lands under the field it is about with
* the focus on it; what happened is said in the status line, and a signed-in
* reader is given the way on rather than moved.
*/
export function PasswordSignIn({ links, demoNote, signIn, continueWith }: PasswordSignInProps) {
const uid = React.useId()
const id = (name: string) => `${uid}-${name}`
const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState("")
// Unticked until the reader asks: staying signed in on a shared machine is
// a choice to make, not one to undo.
const [remember, setRemember] = React.useState(false)
const [error, setError] = React.useState<{ field: FieldName; message: string } | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [signedIn, setSignedIn] = React.useState(false)
const [said, setSaid] = React.useState("")
const onward = React.useRef<HTMLAnchorElement>(null)
// The focus goes to a refused field once the render that marks it is on the
// screen — aria-invalid set, the message under it and named by it — so a
// screen reader reads the field with its error rather than before it. The
// count makes a second refusal of the same field a request of its own.
const [focusRequest, setFocusRequest] = React.useState<{ field: string; n: number } | null>(null)
React.useEffect(() => {
if (focusRequest) document.getElementById(`${uid}-${focusRequest.field}`)?.focus()
}, [focusRequest, uid])
// Signing in swaps the pressed button for the link on; the focus follows it
// rather than falling to the top of the page.
React.useEffect(() => {
if (signedIn) onward.current?.focus()
}, [signedIn])
function refuse(field: FieldName, message: string) {
setError({ field, message })
setFocusRequest((last) => ({ field, n: (last?.n ?? 0) + 1 }))
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setRefusal(null)
if (!isEmail(email.trim())) return refuse("email", "Enter your work email, like name@example.com.")
if (!password) return refuse("password", "Enter your password.")
const form = new FormData()
form.set("email", email.trim())
form.set("password", password)
if (remember) form.set("remember", "on")
setPending(true)
let result: AuthResult
try {
result = await signIn(form)
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
result = { ok: false, error: { code: "unknown", message: "Your sign-in did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
const field = result.error.field
if (field === "email" || field === "password") return refuse(field, result.error.message)
return setRefusal(result.error.message)
}
setSignedIn(true)
setSaid(
`Signed in as ${result.data.user.name}${remember ? ", and kept signed in on this device for 30 days" : ""}. Your dashboard opens next.`
)
}
async function provider(choice: SocialAuthProvider) {
setRefusal(null)
let result: AuthResult<{ provider: string }>
try {
result = await continueWith(choice)
} catch {
result = { ok: false, error: { code: "unknown", message: "The request did not reach the server. Try again." } }
}
if (!result.ok) return setRefusal(result.error.message)
setSaid(`${result.data.provider} would open now to confirm it is you, then bring you back here signed in.`)
}
const invalid = (field: FieldName) => error?.field === field || undefined
const described = (field: FieldName) => (error?.field === field ? id(`${field}-error`) : undefined)
const message = (field: FieldName) =>
error?.field === field ? <FieldError id={id(`${field}-error`)}>{error.message}</FieldError> : null
const required = <span className="font-normal text-muted-foreground">(required)</span>
return (
<div className="flex flex-col gap-4">
<form noValidate onSubmit={submit} aria-label="Sign in with your password" className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor={id("email")}>Work email {required}</FieldLabel>
<Input
id={id("email")}
type="email"
autoComplete="email"
placeholder="name@example.com"
required
value={email}
aria-invalid={invalid("email")}
aria-describedby={described("email")}
onChange={(event) => {
setEmail(event.target.value)
setError(null)
}}
/>
{message("email")}
</Field>
<Field>
<div className="flex items-baseline justify-between gap-3">
<FieldLabel htmlFor={id("password")}>Password {required}</FieldLabel>
<Link
href={links.forgot}
className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Forgot password?
</Link>
</div>
<PasswordInput
id={id("password")}
required
value={password}
aria-invalid={invalid("password")}
aria-describedby={described("password")}
onChange={(event) => {
setPassword(event.target.value)
setError(null)
}}
/>
{message("password")}
</Field>
<div className="flex items-center gap-2.5">
<Checkbox
id={id("remember")}
checked={remember}
onCheckedChange={(checked) => setRemember(checked === true)}
/>
<Label htmlFor={id("remember")} className="font-normal text-muted-foreground">
Keep me signed in on this device for 30 days
</Label>
</div>
{refusal ? (
<Callout variant="danger" role="alert" title="You are not signed in">
{refusal}
</Callout>
) : null}
{signedIn ? (
<Link ref={onward} href={links.next} className={buttonVariants()}>
Open the dashboard
</Link>
) : (
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
Sign in
</Button>
)}
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</form>
<div className="flex flex-col gap-3">
<div aria-hidden="true" className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
or
<span className="h-px flex-1 bg-border" />
</div>
<SocialAuthButtons providers={["google", "github"]} layout="stack" onSelect={provider} />
</div>
<p className="text-xs text-muted-foreground">
{demoNote} <span className="font-mono text-foreground">wrong</span>. New to Northwind?{" "}
<Link href={links.signUp} className="text-foreground underline underline-offset-4">
Create an account
</Link>
</p>
</div>
)
}/**
* What `sign-in-forms-01` reads and calls: the three newest stable releases
* in `db.releases`, which the panel beside the form shows a returning reader,
* and the two things the form can ask of the server — a password sign-in,
* checked and handed to the auth adapter by the kit's `createAuthActions`, and
* the start of a sign-in with an outside provider, which only says where the
* reader would go.
*
* Selectors and server-side calls, reading `db` inside functions. The adapter
* stays on the server: the island takes plain props and the server functions
* its section hands it, and never imports this module.
*/
import { createAuthActions, mockAuthAdapter, type AuthResult } from "@/lib/auth-adapter"
import { db } from "@/lib/sample-data"
export type { AuthResult }
/** Where the form's links go: the reset flow, the sign-up page, and the dashboard after signing in. */
export const LINKS = { forgot: "/forgot-password", signUp: "/sign-up", next: "/saas", changelog: "/changelog" }
/** What the mock adapter accepts, said once so the preview can be tried. */
export const DEMO_NOTE = "This form runs on the kit's mock adapter: any password works except"
export type ReleaseNote = { version: string; title: string; summary: string; date: string; iso: string }
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** The three newest stable releases, newest first. */
export function whatsNew(): ReleaseNote[] {
return db.releases
.all()
.filter((release) => release.channel === "stable")
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 3)
.map((release) => ({
version: release.version,
title: release.title,
summary: release.summary,
date: DAY.format(release.date),
iso: release.date.toISOString().slice(0, 10),
}))
}
/** The longest an address can be (RFC 5321), and the longest password the form takes. */
const MAX_EMAIL = 254
const MAX_PASSWORD = 256
/**
* Signs in from the form's FormData — email, password, remember — through the
* adapter. The types are erased at the action boundary, so anything but a
* form is refused, and an address or a password longer than any real one is
* refused before the adapter's pattern reads it.
*/
export async function signInWithPassword(formData: FormData): Promise<AuthResult> {
if (!(formData instanceof FormData))
return { ok: false, error: { code: "invalid_input", message: "That is not a sign-in this form sends." } }
const email = formData.get("email")
if (typeof email !== "string" || email.length > MAX_EMAIL)
return {
ok: false,
error: { code: "invalid_input", field: "email", message: "Enter your work email, like name@example.com." },
}
const password = formData.get("password")
if (typeof password !== "string" || password.length > MAX_PASSWORD)
return {
ok: false,
error: { code: "invalid_input", field: "password", message: "Enter your password, up to 256 characters." },
}
return createAuthActions(mockAuthAdapter).signIn(formData)
}
/** The providers a reader can sign in with instead, and how each writes its own name. */
const PROVIDERS = new Map([
["google", "Google"],
["github", "GitHub"],
])
/**
* Starts a sign-in with an outside provider. Nothing signs in here: this is
* the seam where a real app sends the reader to the provider, and it answers
* with the provider's name so the page can say where they would go. The name
* is looked up in a Map, so "constructor" or "__proto__" is no provider.
*/
export async function continueWith(provider: string): Promise<AuthResult<{ provider: string }>> {
const name = typeof provider === "string" ? PROVIDERS.get(provider) : undefined
if (!name)
return { ok: false, error: { code: "invalid_input", message: "That provider is not set up for this workspace." } }
return { ok: true, data: { provider: name } }
}npx shadcn@latest add @vibra/sign-in-forms-01Magic link with its steps
sign-in-forms-02The three steps of a link sign-in beside the form that starts them; once sent, the second step is current, the sent state says where the link went, and another can be asked for after a stated wait.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MagicLinkSignIn } from "./sign-in-forms-02.client"
import { LINK_MINUTES, RESEND_SECONDS, sendSignInLink } from "./sign-in-forms-02.data"
/**
* Signing in with a link instead of a password, in one framed card: the three
* steps it takes down one side, and the form that starts them beside it — an
* address and a button on one line — which becomes where the link went, how
* long it works and when another can be asked for.
*
* A server component. It hands the island `send`, a server function defined
* here that checks the address and calls the mailer; nothing is read from the
* store, and the answer never says whether an account exists.
*/
export function SignInForms02() {
const titleId = React.useId()
async function send(email: string) {
"use server"
return sendSignInLink(email)
}
return (
<section
data-section="sign-in-forms-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sign in with a link</h2>
</CardTitle>
<CardDescription>No password: we email a link that signs you in once.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<MagicLinkSignIn minutes={LINK_MINUTES} resendSeconds={RESEND_SECONDS} send={send} />
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms02"use client"
import * as React from "react"
import { CircleCheckIcon, MailIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Countdown } from "@/components/ui/countdown"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult } from "./sign-in-forms-02.data"
export type MagicLinkSignInProps = {
minutes: number
resendSeconds: number
send: (email: string) => Promise<AuthResult<{ sentTo: string; minutes: number }>>
}
/**
* The three steps of a link sign-in beside the form that starts them. Before
* the link goes the first step is the current one; once it is sent the form
* becomes the sent state — where it went, how long it works — and the second
* step is. Another link can be asked for after a stated wait, counted down
* where the button is; the moment it can, the status line says so. "Use a
* different address" goes back to the form with the address still in it.
*/
export function MagicLinkSignIn({ minutes, resendSeconds, send }: MagicLinkSignInProps) {
const uid = React.useId()
const [email, setEmail] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [sentTo, setSentTo] = React.useState<string | null>(null)
const [until, setUntil] = React.useState<Date | null>(null)
const [ready, setReady] = React.useState(false)
const [said, setSaid] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
const heading = React.useRef<HTMLHeadingElement>(null)
const field = React.useRef<HTMLInputElement>(null)
const moved = React.useRef<"heading" | "field" | null>(null)
React.useEffect(() => {
const target = moved.current
moved.current = null
if (target === "heading") heading.current?.focus()
if (target === "field") field.current?.focus()
}, [sentTo])
async function request(address: string) {
setRefusal(null)
setPending(true)
let result: AuthResult<{ sentTo: string; minutes: number }>
try {
result = await send(address)
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
result = { ok: false, error: { code: "unknown", message: "Your request did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
if (result.error.field !== "email") return setRefusal(result.error.message)
setError(result.error.message)
return field.current?.focus()
}
const again = sentTo !== null
if (!again) moved.current = "heading"
setSentTo(result.data.sentTo)
setReady(false)
setUntil(new Date(Date.now() + resendSeconds * 1000))
setSaid(again ? `Sent another link to ${result.data.sentTo}.` : `A link is on its way to ${result.data.sentTo}.`)
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!isEmail(email.trim())) {
setError("Enter your work email, like name@example.com.")
return field.current?.focus()
}
setError(null)
void request(email)
}
const step = sentTo ? 1 : 0
const steps = [
{ title: "Enter your work email", detail: "The one your team invited." },
{ title: "Open the link we send", detail: `On this device, within ${minutes} minutes.` },
{ title: "You are in", detail: "No password to remember or reset." },
]
return (
<div className="grid lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<ol
aria-label="How a link sign-in works"
className="flex flex-col gap-4 border-b bg-surface p-(--card-spacing) md:p-8 lg:border-e lg:border-b-0"
>
{steps.map((entry, index) => (
<li key={entry.title} aria-current={index === step ? "step" : undefined} className="flex items-start gap-3">
<span
aria-hidden="true"
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums",
index < step && "border-transparent bg-foreground text-background",
index === step && "border-foreground font-semibold"
)}
>
{index + 1}
</span>
<span className="flex flex-col gap-0.5">
<span
className={cn("text-sm", index === step ? "font-semibold" : index > step && "text-muted-foreground")}
>
{entry.title}
{index < step ? <span className="sr-only"> (done)</span> : null}
</span>
<span className="text-xs text-muted-foreground">{entry.detail}</span>
</span>
</li>
))}
</ol>
<div className="flex flex-col gap-4 p-(--card-spacing) md:p-8">
{sentTo ? (
<div className="flex max-w-md flex-col items-start gap-4">
<span
aria-hidden="true"
className="flex size-9 items-center justify-center rounded-lg bg-surface ring-1 ring-border"
>
<MailIcon className="size-4" />
</span>
<h3 ref={heading} tabIndex={-1} className="text-base font-semibold outline-none">
Check your inbox
</h3>
<p className="text-sm text-pretty text-muted-foreground">
A link is on its way to <span className="font-medium text-foreground">{sentTo}</span>. It signs you in
once, for the next {minutes} minutes.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{/* Off while the wait runs and while it sends, but it keeps the focus it was pressed with. */}
<Button
type="button"
variant="outline"
disabled={!ready || pending}
focusableWhenDisabled
aria-busy={pending || undefined}
onClick={() => void request(sentTo)}
>
{pending ? <Spinner aria-hidden="true" /> : null}
Send another link
</Button>
{until && !ready ? (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
You can ask for another in
<Countdown
to={until}
format="clock"
onComplete={() => {
setReady(true)
setSaid("You can ask for another link now.")
}}
valueClassName="text-sm font-medium"
/>
</span>
) : null}
</div>
<Button
type="button"
variant="link"
className="h-auto p-0"
onClick={() => {
moved.current = "field"
setSentTo(null)
setSaid("")
setRefusal(null)
}}
>
Use a different address
</Button>
</div>
) : (
<form
noValidate
onSubmit={submit}
aria-label="Email me a sign-in link"
className="flex max-w-md flex-col gap-4"
>
<Field>
<FieldLabel htmlFor={`${uid}-email`}>
Work email <span className="font-normal text-muted-foreground">(required)</span>
</FieldLabel>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
ref={field}
id={`${uid}-email`}
type="email"
autoComplete="email"
placeholder="name@example.com"
required
value={email}
aria-invalid={error ? true : undefined}
aria-describedby={[`${uid}-hint`, error ? `${uid}-error` : ""].filter(Boolean).join(" ")}
onChange={(event) => {
setEmail(event.target.value)
setError(null)
}}
/>
<Button
type="submit"
className="shrink-0"
disabled={pending}
focusableWhenDisabled
aria-busy={pending || undefined}
>
{pending ? <Spinner aria-hidden="true" /> : null}
Email me a link
</Button>
</div>
<FieldDescription id={`${uid}-hint`}>We never say whether an address has an account.</FieldDescription>
{error ? <FieldError id={`${uid}-error`}>{error}</FieldError> : null}
</Field>
</form>
)}
{refusal ? (
<Callout variant="danger" role="alert" title="The link was not sent" className="max-w-md">
{refusal}
</Callout>
) : null}
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</div>
</div>
)
}/**
* What `sign-in-forms-02` needs: how long a sign-in link works and how long
* the page waits before it offers another, and `sendSignInLink`, which the
* section's server function calls. The auth adapter has no link ceremony of
* its own — borrowing its password reset for one would mail resets to anyone
* who asked to sign in — so this is the seam: the address is checked here,
* and your mailer is called here.
*
* No store is read: a sign-in link is about the address the reader types, and
* the answer never says whether an account exists for it.
*/
import { type AuthResult } from "@/lib/auth-adapter"
import { isEmail } from "@/lib/validation"
export type { AuthResult }
/** How long a link signs a reader in for, in minutes. */
export const LINK_MINUTES = 15
/** How long the page waits before it offers to send another link, in seconds. */
export const RESEND_SECONDS = 30
/** The longest an address can be (RFC 5321); a longer one never reaches the email pattern. */
const MAX_EMAIL = 254
/**
* Sends a single-use sign-in link. It answers the same for every address in
* the shape of one — registered or not — because saying which addresses have
* accounts is how a list of them gets collected. The types are erased at the
* action boundary, so anything but text of an address's length is refused,
* never thrown on.
*/
export async function sendSignInLink(email: string): Promise<AuthResult<{ sentTo: string; minutes: number }>> {
const address = typeof email === "string" ? email.trim().toLowerCase() : ""
if (address.length > MAX_EMAIL || !isEmail(address))
return {
ok: false,
error: { code: "invalid_input", field: "email", message: "Enter your work email, like name@example.com." },
}
return { ok: true, data: { sentTo: address, minutes: LINK_MINUTES } }
}npx shadcn@latest add @vibra/sign-in-forms-02Single sign-on found from the work email
sign-in-forms-03The work email first; from its domain the workspace it belongs to — named, with the way to its identity provider — or an answer in words for why that address signs in another way.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SsoSignIn } from "./sign-in-forms-03.client"
import { OTHER_WAYS, findWorkspace, startSingleSignOn } from "./sign-in-forms-03.data"
/**
* Company single sign-on, in one narrow framed card: the work email first,
* and from its domain the workspace it belongs to — named, with the way to
* its identity provider, or an answer in words for why that address signs in
* some other way.
*
* A server component. It hands the island two server functions defined here:
* `find`, which looks the domain up among the companies on the books, and
* `start`, which looks it up again before it starts anything.
*/
export function SignInForms03() {
const titleId = React.useId()
async function find(email: string) {
"use server"
return findWorkspace(email)
}
async function start(domain: string) {
"use server"
return startSingleSignOn(domain)
}
return (
<section
data-section="sign-in-forms-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
{/* An address-first sign-in is one column of decisions: the card holds to it. */}
<Card className="mx-auto max-w-lg">
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sign in with your company</h2>
</CardTitle>
<CardDescription>Single sign-on, found from your work email.</CardDescription>
</CardHeader>
<CardContent className="md:p-6">
<SsoSignIn otherWays={OTHER_WAYS} find={find} start={start} />
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms03"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult, Verdict } from "./sign-in-forms-03.data"
export type SsoSignInProps = {
otherWays: { password: string; link: string }
find: (email: string) => Promise<AuthResult<Verdict>>
start: (domain: string) => Promise<AuthResult<{ company: string }>>
}
/** What each answer says, in a heading and a line, and the tone it is drawn in. */
function explain(verdict: Verdict): { title: string; detail: string; tone: "default" | "warning" } {
switch (verdict.kind) {
case "password":
return {
title: `${verdict.company} signs in with a password`,
detail: `Its workspace is on ${verdict.plan}, and single sign-on comes with ${verdict.ssoPlan}.`,
tone: "default",
}
case "suspended":
return {
title: `${verdict.company}'s workspace is suspended`,
detail:
"Single sign-on is off until an admin settles the account. Ask the person who runs Northwind for your team.",
tone: "warning",
}
case "cancelled":
return {
title: `${verdict.company} closed its workspace`,
detail: "There is nothing to sign in to. An admin can reopen it from the billing page.",
tone: "default",
}
default:
return {
title: `No workspace signs in with single sign-on for @${verdict.domain}`,
detail: "Your address may belong to a workspace that uses a password, or none yet.",
tone: "default",
}
}
}
/**
* Single sign-on, found by address: the work email first, and from its domain
* the workspace it belongs to. A workspace that uses its own identity
* provider is named, with the button that goes there; any other answer says
* why in words — a plan without single sign-on, a suspended or closed
* workspace, a domain nobody uses — and offers the other ways in. The answer
* takes the focus and is said in the status line; the address is kept.
*/
export function SsoSignIn({ otherWays, find, start }: SsoSignInProps) {
const uid = React.useId()
const [email, setEmail] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [verdict, setVerdict] = React.useState<Verdict | null>(null)
const [said, setSaid] = React.useState({ text: "", good: false, shown: false })
const answer = React.useRef<HTMLHeadingElement>(null)
const field = React.useRef<HTMLInputElement>(null)
const moved = React.useRef<"answer" | "field" | null>(null)
React.useEffect(() => {
const target = moved.current
moved.current = null
if (target === "answer") answer.current?.focus()
if (target === "field") field.current?.focus()
}, [verdict])
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal(null)
if (!isEmail(email.trim())) {
setError("Enter your work email, like name@example.com.")
return field.current?.focus()
}
setError(null)
setPending(true)
let result: AuthResult<Verdict>
try {
result = await find(email)
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
result = { ok: false, error: { code: "unknown", message: "Your address did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
if (result.error.field !== "email") return setRefusal(result.error.message)
setError(result.error.message)
return field.current?.focus()
}
moved.current = "answer"
setVerdict(result.data)
setSaid(
result.data.kind === "sso"
? { text: `Found ${result.data.company}. It signs in through its own identity provider.`, good: true, shown: false }
: { text: `${explain(result.data).title}.`, good: false, shown: false }
)
}
async function go(domain: string) {
setRefusal(null)
setPending(true)
let result: AuthResult<{ company: string }>
try {
result = await start(domain)
} catch {
result = { ok: false, error: { code: "unknown", message: "The request did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) return setRefusal(result.error.message)
setSaid({
text: `${result.data.company}'s identity provider would open now, and bring you back to Northwind signed in.`,
good: true,
shown: true,
})
}
function again() {
moved.current = "field"
setVerdict(null)
setRefusal(null)
setSaid({ text: "", good: false, shown: false })
}
const others = (
<div className="flex flex-wrap gap-2">
<Link href={otherWays.password} className={buttonVariants({ variant: "outline", size: "sm" })}>
Sign in with a password
</Link>
<Link href={otherWays.link} className={buttonVariants({ variant: "outline", size: "sm" })}>
Email me a link
</Link>
</div>
)
return (
<div className="flex flex-col gap-5">
{verdict === null ? (
<form noValidate onSubmit={submit} aria-label="Find your workspace" className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor={`${uid}-email`}>
Work email <span className="font-normal text-muted-foreground">(required)</span>
</FieldLabel>
<Input
ref={field}
id={`${uid}-email`}
type="email"
autoComplete="email"
placeholder="name@example.com"
required
value={email}
aria-invalid={error ? true : undefined}
aria-describedby={[`${uid}-hint`, error ? `${uid}-error` : ""].filter(Boolean).join(" ")}
onChange={(event) => {
setEmail(event.target.value)
setError(null)
}}
/>
<FieldDescription id={`${uid}-hint`}>
Your company’s domain tells us which workspace is yours.
</FieldDescription>
{error ? <FieldError id={`${uid}-error`}>{error}</FieldError> : null}
</Field>
{refusal ? (
<Callout variant="danger" role="alert" title="Your workspace was not looked up">
{refusal}
</Callout>
) : null}
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
Continue with single sign-on
</Button>
</form>
) : verdict.kind === "sso" ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-foreground text-sm font-semibold text-background"
>
{verdict.company.charAt(0)}
</span>
<div className="flex min-w-0 flex-col">
<h3 ref={answer} tabIndex={-1} className="truncate text-sm font-semibold outline-none">
{verdict.company}
</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{verdict.seats} people sign in through @{verdict.domain}
</p>
</div>
</div>
{refusal ? (
<Callout variant="danger" role="alert" title="Single sign-on did not start">
{refusal}
</Callout>
) : null}
<Button
type="button"
disabled={pending}
focusableWhenDisabled
aria-busy={pending || undefined}
onClick={() => void go(verdict.domain)}
>
{pending ? <Spinner aria-hidden="true" /> : null}
Continue to {verdict.company}
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
</Button>
</div>
) : (
<div className="flex flex-col gap-4">
<Callout
variant={explain(verdict).tone}
title={
<h3 ref={answer} tabIndex={-1} className="outline-none">
{explain(verdict).title}
</h3>
}
>
{explain(verdict).detail}
</Callout>
{verdict.kind === "password" || verdict.kind === "unknown" ? others : null}
</div>
)}
{verdict ? (
<p className="text-sm text-muted-foreground">
Signing in as <span className="font-medium text-foreground">{email.trim().toLowerCase()}</span>.{" "}
<Button type="button" variant="link" className="h-auto p-0 align-baseline" onClick={again}>
Use a different email
</Button>
</p>
) : null}
{/* An answer the panel above already shows is said aloud only; what the provider would do next is shown too. */}
<p role="status" aria-live="polite" className={said.shown ? "flex min-h-5 items-start gap-2 text-sm" : "sr-only"}>
{said.good && said.shown ? <CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" /> : null}
{said.text}
</p>
</div>
)
}/**
* What `sign-in-forms-03` looks up: which workspace a work address belongs
* to, by its domain, among the companies in `db.customers` — and whether
* that workspace signs in through its own identity provider. Single sign-on
* comes with the plan in `db.plans` whose features include it, so a company
* on any other plan signs in with a password; a suspended or cancelled
* workspace has it switched off. `findWorkspace` and `startSingleSignOn` are
* what the section's server functions call; the second looks the domain up
* again rather than trusting the page.
*
* Selectors and checks, reading `db` inside functions on the server. The
* island takes plain props and never imports this module.
*/
import { type AuthResult } from "@/lib/auth-adapter"
import { db } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"
export type { AuthResult }
/** The other ways in, for an address whose workspace does not use single sign-on. */
export const OTHER_WAYS = { password: "/sign-in", link: "/sign-in/link" }
/** What an address's domain says about how its owner signs in. */
export type Verdict =
| { kind: "sso"; domain: string; company: string; seats: number }
| { kind: "password"; domain: string; company: string; plan: string; ssoPlan: string }
| { kind: "suspended" | "cancelled"; domain: string; company: string }
| { kind: "unknown"; domain: string }
const PLAN_NAMES: Record<string, string> = { free: "Free", starter: "Starter", team: "Team", enterprise: "Enterprise" }
/** The plan single sign-on comes with: the one whose features name it. */
export function ssoPlan(): string {
return db.plans.all().find((plan) => plan.features.some((feature) => /\bSSO\b/.test(feature)))?.name ?? "Enterprise"
}
function verdictFor(domain: string): Verdict {
const customer = db.customers.all().find((row) => row.email.toLowerCase().endsWith(`@${domain}`))
if (!customer) return { kind: "unknown", domain }
const company = customer.company
if (customer.status === "churned") return { kind: "cancelled", domain, company }
if (customer.status === "suspended") return { kind: "suspended", domain, company }
const plan = PLAN_NAMES[customer.plan] ?? customer.plan
const sso = ssoPlan()
if (plan !== sso) return { kind: "password", domain, company, plan, ssoPlan: sso }
return { kind: "sso", domain, company, seats: customer.seats }
}
/** The longest an address (RFC 5321) and a domain (RFC 1035) can be; longer ones never reach a pattern. */
const MAX_EMAIL = 254
const MAX_DOMAIN = 253
/**
* Finds the workspace a work address belongs to, by its domain, and says how
* its people sign in. The types are erased at the action boundary, so
* anything but text of an address's length is refused, never thrown on.
*/
export async function findWorkspace(email: string): Promise<AuthResult<Verdict>> {
const address = typeof email === "string" ? email.trim().toLowerCase() : ""
if (address.length > MAX_EMAIL || !isEmail(address))
return {
ok: false,
error: { code: "invalid_input", field: "email", message: "Enter your work email, like name@example.com." },
}
return { ok: true, data: verdictFor(address.slice(address.indexOf("@") + 1)) }
}
/**
* Starts the single sign-on for a domain, after looking it up again. Nothing
* signs in here: this is where a real app redirects to the company's identity
* provider, and it answers with the company so the page can say where.
*/
export async function startSingleSignOn(domain: string): Promise<AuthResult<{ company: string }>> {
const name = typeof domain === "string" ? domain.trim().toLowerCase() : ""
const verdict: Verdict = name && name.length <= MAX_DOMAIN ? verdictFor(name) : { kind: "unknown", domain: "" }
if (verdict.kind !== "sso")
return {
ok: false,
error: { code: "invalid_credentials", message: "That workspace does not sign in with single sign-on any more." },
}
return { ok: true, data: { company: verdict.company } }
}npx shadcn@latest add @vibra/sign-in-forms-03Passkey account chooser with a password fallback
sign-in-forms-04The accounts this device remembers, each with its face and a passkey button; a passkey that is not here is refused in the account's row and opens the password there instead.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AccountChooser } from "./sign-in-forms-04.client"
import { OTHER_ACCOUNT_HREF, rememberedAccounts, signInWithPassword, signInWithPasskey } from "./sign-in-forms-04.data"
/**
* Signing in with a passkey, in one framed card: the accounts this device
* remembers, each with its face and a passkey button, and the password one
* press away in the same row — the fallback a missing passkey opens for you.
*
* A server component. It reads the remembered accounts through
* `./sign-in-forms-04.data` and hands the island two server functions defined
* here, `passkey` and `password`, each of which reads the member again and
* takes the address from the row.
*/
export function SignInForms04() {
const titleId = React.useId()
async function passkey(id: string) {
"use server"
return signInWithPasskey(id)
}
async function password(id: string, secret: string) {
"use server"
return signInWithPassword(id, secret)
}
return (
<section
data-section="sign-in-forms-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card className="mx-auto max-w-2xl">
<CardHeader>
<CardTitle>
<h2 id={titleId}>Choose an account</h2>
</CardTitle>
<CardDescription>Sign in with the passkey on this device, or with a password.</CardDescription>
</CardHeader>
<CardContent className="md:px-6">
<AccountChooser
accounts={rememberedAccounts()}
otherAccount={OTHER_ACCOUNT_HREF}
passkey={passkey}
password={password}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms04"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, KeyRoundIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FieldError, FieldLabel } from "@/components/ui/field"
import { PasswordInput } from "@/components/ui/password-input"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult, Remembered } from "./sign-in-forms-04.data"
export type AccountChooserProps = {
accounts: Remembered[]
otherAccount: string
passkey: (id: string) => Promise<AuthResult<{ name: string }>>
password: (id: string, password: string) => Promise<AuthResult<{ name: string }>>
}
/**
* The accounts this device remembers, each with its face and a passkey
* button; the password is always one press away. A passkey that is not on
* this device is refused in the account's own row, and the password field
* opens there with the focus in it — the way in is never a dead end. What
* signed in, and how, is said in the status line.
*/
export function AccountChooser({ accounts, otherAccount, passkey, password }: AccountChooserProps) {
const uid = React.useId()
const [open, setOpen] = React.useState<string | null>(null)
const [notices, setNotices] = React.useState<Record<string, { title: string; message: string }>>({})
const [secret, setSecret] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [signedIn, setSignedIn] = React.useState<string | null>(null)
const [said, setSaid] = React.useState("")
const focusPassword = React.useRef(false)
const confirmed = React.useRef<HTMLSpanElement>(null)
// Signing in swaps the row's buttons (or its password form) for "Signed in";
// the focus follows, rather than falling to the top of the page.
React.useEffect(() => {
if (signedIn) confirmed.current?.focus()
}, [signedIn])
React.useEffect(() => {
if (!focusPassword.current || !open) return
focusPassword.current = false
document.getElementById(`${uid}-${open}-password`)?.focus()
}, [open, uid])
function fallBack(id: string) {
focusPassword.current = true
setOpen(id)
setSecret("")
setError(null)
}
/** Puts a refusal in the account's row, or takes it away. */
function notify(id: string, notice: { title: string; message: string } | null) {
setNotices((all) => {
const next = { ...all }
if (notice) next[id] = notice
else delete next[id]
return next
})
}
async function tryPasskey(account: Remembered) {
notify(account.id, null)
let result: AuthResult<{ name: string }>
try {
result = await passkey(account.id)
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
return notify(account.id, {
title: "You are not signed in",
message: "The passkey check did not reach the server. Try again.",
})
}
if (!result.ok) {
// A missing passkey opens the password, the way in that is left; an account that can no longer sign in
// at all is told so, with no password to try.
if (result.error.code !== "invalid_credentials")
return notify(account.id, { title: "This account cannot sign in", message: result.error.message })
notify(account.id, { title: "Passkey not found", message: result.error.message })
return fallBack(account.id)
}
setOpen(null)
setSignedIn(account.id)
setSaid(`Signed in as ${result.data.name} with the passkey on this device.`)
}
async function submit(event: React.FormEvent<HTMLFormElement>, account: Remembered) {
event.preventDefault()
if (!secret) {
setError("Enter the password.")
return document.getElementById(`${uid}-${account.id}-password`)?.focus()
}
setError(null)
notify(account.id, null)
setPending(true)
let result: AuthResult<{ name: string }>
try {
result = await password(account.id, secret)
} catch {
return notify(account.id, {
title: "You are not signed in",
message: "Your password did not reach the server. Try again.",
})
} finally {
setPending(false)
}
if (!result.ok) {
if (result.error.field !== "password") {
setOpen(null)
return notify(account.id, { title: "This account cannot sign in", message: result.error.message })
}
setError(result.error.message)
return document.getElementById(`${uid}-${account.id}-password`)?.focus()
}
setSignedIn(account.id)
setOpen(null)
setSaid(`Signed in as ${result.data.name} with a password.`)
}
return (
<div className="flex flex-col gap-4">
<ul aria-label="Accounts on this device" className="flex flex-col divide-y border-y">
{accounts.map((account) => (
<li key={account.id} className="flex flex-col gap-3 py-4">
<div className="flex flex-wrap items-center gap-3">
<Avatar size="lg">
<AvatarImage src={account.face} alt="" />
<AvatarFallback>{getInitials(account.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 basis-40 flex-col">
<span className="truncate text-sm font-medium">{account.name}</span>
<span className="truncate text-xs text-muted-foreground">
{account.email} · here {account.lastHere}
</span>
</div>
{signedIn === account.id ? (
<span ref={confirmed} tabIndex={-1} className="flex items-center gap-1.5 text-xs text-success outline-none">
<CircleCheckIcon aria-hidden="true" className="size-4" />
Signed in
</span>
) : (
<div className="flex items-center gap-1">
{/* A passkey is offered only where this device holds one; the password is always there. */}
{account.hasPasskey ? (
<AsyncButton
type="button"
size="sm"
aria-label={`Use passkey for ${account.name}`}
onClick={() => tryPasskey(account)}
>
<KeyRoundIcon aria-hidden="true" data-icon="inline-start" />
Use passkey
</AsyncButton>
) : null}
<Button
type="button"
variant={account.hasPasskey ? "ghost" : "outline"}
size="sm"
aria-expanded={open === account.id}
aria-controls={open === account.id ? `${uid}-${account.id}-form` : undefined}
aria-label={`Use password for ${account.name}`}
onClick={() => (open === account.id ? setOpen(null) : fallBack(account.id))}
>
Password
</Button>
</div>
)}
</div>
{notices[account.id] ? (
<Callout variant="danger" role="alert" title={notices[account.id].title}>
{notices[account.id].message}
</Callout>
) : null}
{open === account.id ? (
<form
id={`${uid}-${account.id}-form`}
noValidate
onSubmit={(event) => void submit(event, account)}
className="flex flex-col gap-2"
>
{/* Whose password this is, for a password manager: the address is on the row above. */}
<input type="text" name="username" autoComplete="username" value={account.email} readOnly hidden />
<FieldLabel htmlFor={`${uid}-${account.id}-password`}>
Password for {account.name.split(" ")[0]}{" "}
<span className="font-normal text-muted-foreground">(required)</span>
</FieldLabel>
<div className="flex gap-2">
<PasswordInput
id={`${uid}-${account.id}-password`}
required
value={secret}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${uid}-${account.id}-error` : undefined}
onChange={(event) => {
setSecret(event.target.value)
setError(null)
}}
/>
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
Sign in
</Button>
</div>
{error ? <FieldError id={`${uid}-${account.id}-error`}>{error}</FieldError> : null}
</form>
) : null}
</li>
))}
</ul>
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
<p className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>
In this demo, a person with a second factor has a passkey here, and any password works except{" "}
<span className="font-mono text-foreground">wrong</span>.
</span>
<Link href={otherAccount} className="text-sm text-foreground underline underline-offset-4">
Use another account
</Link>
</p>
</div>
)
}/**
* What `sign-in-forms-04` reads and calls: the accounts this device remembers
* — the three members in `db.members` who were here most recently — and
* whether each has a passkey on it, which the demo reads as the member having
* set up a second factor. `signInWithPasskey` and `signInWithPassword` are
* what the section's server functions call. Both read the member again and
* take the address from the row, never from the page; the password goes to
* the auth adapter like any other sign-in.
*
* Selectors and server-side calls, reading `db` inside functions. The island
* takes plain props and never imports this module.
*/
import { mockAuthAdapter, type AuthResult } from "@/lib/auth-adapter"
import { db } from "@/lib/sample-data"
export type { AuthResult }
/** Where "Use another account" goes. */
export const OTHER_ACCOUNT_HREF = "/sign-in"
export type Remembered = {
id: string
name: string
email: string
face?: string
hasPasskey: boolean
lastHere: string
}
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** The three active members who were here most recently, most recent first. */
export function rememberedAccounts(): Remembered[] {
return db.members
.all()
.filter((member) => member.status === "active")
.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime())
.slice(0, 3)
.map((member) => ({
id: member.id,
name: member.name,
email: member.email,
face: member.avatarUrl,
hasPasskey: member.twoFactor,
lastHere: DAY.format(member.lastActiveAt),
}))
}
const refuse = (code: "invalid_credentials" | "unknown", message: string, field?: "password"): AuthResult<never> => ({
ok: false,
error: { code, message, ...(field ? { field } : {}) },
})
/** The longest password the form takes. */
const MAX_PASSWORD = 256
/** The member behind an id, when the id is text and names one; the types are erased at the action boundary. */
const memberFor = async (memberId: unknown) => (typeof memberId === "string" ? db.members.get(memberId) : undefined)
/**
* Checks the passkey this device holds for a member. Mocked: a real one asks
* the browser for a signed assertion and verifies it against the public key
* on file. A member with no passkey here is refused, in words that point at
* the password instead.
*/
export async function signInWithPasskey(memberId: string): Promise<AuthResult<{ name: string }>> {
const member = await memberFor(memberId)
if (!member || member.status !== "active")
return refuse("unknown", "That account is no longer active on this workspace.")
if (!member.twoFactor)
return refuse(
"invalid_credentials",
`There is no passkey for ${member.name.split(" ")[0]} on this device. Use the password instead.`
)
return { ok: true, data: { name: member.name } }
}
/**
* Signs a remembered member in with a password, through the adapter, with the
* address on their row. A password that is not text, or longer than any real
* one, is refused on the field, never thrown on.
*/
export async function signInWithPassword(memberId: string, password: string): Promise<AuthResult<{ name: string }>> {
const member = await memberFor(memberId)
if (!member || member.status !== "active")
return refuse("unknown", "That account is no longer active on this workspace.")
if (typeof password !== "string" || password.length > MAX_PASSWORD)
return refuse("invalid_credentials", "Enter the password, up to 256 characters.", "password")
if (!password) return refuse("invalid_credentials", "Enter the password.", "password")
const result = await mockAuthAdapter.signIn({ email: member.email, password })
if (!result.ok) return result
return { ok: true, data: { name: member.name } }
}npx shadcn@latest add @vibra/sign-in-forms-04Two-factor code in six boxes
sign-in-forms-05The second step of signing in: six boxes for an authenticator code that a paste fills at once and the sixth digit sends, with wrong codes read out and counted, and a recovery code one press away.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { TwoFactorChallenge } from "./sign-in-forms-05.client"
import { TRUST_DAYS, challenge, verifySecondFactor, type SecondFactor } from "./sign-in-forms-05.data"
/**
* The second step of signing in, in one narrow framed card: six boxes for the
* code from an authenticator app, a recovery code one press away, and a
* choice to trust the device for a while.
*
* A server component. It reads who is signing in through
* `./sign-in-forms-05.data` and hands the island `verify`, a server function
* defined here that checks the code through the auth adapter and counts the
* wrong ones on the server.
*/
export function SignInForms05() {
const titleId = React.useId()
const who = challenge()
async function verify(input: SecondFactor) {
"use server"
return verifySecondFactor(input)
}
return (
<section
data-section="sign-in-forms-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle>
<h2 id={titleId}>Check it is you</h2>
</CardTitle>
<CardDescription>Two-factor is on for {who.name}'s account.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:p-6">
<TwoFactorChallenge maskedEmail={who.maskedEmail} trustDays={TRUST_DAYS} verify={verify} />
<p className="text-xs text-muted-foreground">
This check runs on the kit's mock adapter: any six digits work except{" "}
<span className="font-mono whitespace-nowrap text-foreground">000000</span>, and any recovery code in the right shape except{" "}
<span className="font-mono whitespace-nowrap text-foreground">00000-00000</span>.
</p>
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms05"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { REGEXP_ONLY_DIGITS } from "input-otp"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult, SecondFactor } from "./sign-in-forms-05.data"
export type TwoFactorChallengeProps = {
maskedEmail: string
trustDays: number
verify: (input: SecondFactor) => Promise<AuthResult<{ trusted: boolean; via: "code" | "recovery" }>>
}
/**
* The second step of a sign-in: six boxes for the code the authenticator app
* shows. Typing or pasting the sixth digit sends it — a pasted code fills
* every box at once — and a wrong one is refused in words tied to the boxes
* and read out, with how many tries are left. A recovery code is the other
* way in, one press away. The reader can trust this device for a while; what
* happened is said in the status line.
*/
export function TwoFactorChallenge({ maskedEmail, trustDays, verify }: TwoFactorChallengeProps) {
const uid = React.useId()
const [mode, setMode] = React.useState<"code" | "recovery">("code")
const [code, setCode] = React.useState("")
const [recovery, setRecovery] = React.useState("")
const [trust, setTrust] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [done, setDone] = React.useState(false)
const [said, setSaid] = React.useState("")
// Where the focus goes once a render is on the screen: back into the field
// after a refusal, into the field that replaced the other after a switch, and
// to what happened once the check is done. It waits for the commit because
// the field is disabled while a check runs, and a disabled field takes no
// focus: focusing it straight after the answer leaves the focus on the page.
const [focusRequest, setFocusRequest] = React.useState<{ to: "field" | "done"; n: number } | null>(null)
const confirmed = React.useRef<HTMLSpanElement>(null)
const focusNext = (to: "field" | "done") => setFocusRequest((last) => ({ to, n: (last?.n ?? 0) + 1 }))
React.useEffect(() => {
if (!focusRequest) return
if (focusRequest.to === "done") confirmed.current?.focus()
else document.getElementById(`${uid}-${mode}`)?.focus()
}, [focusRequest, mode, uid])
async function check(input: SecondFactor) {
setError(null)
setRefusal(null)
setPending(true)
let result: AuthResult<{ trusted: boolean; via: "code" | "recovery" }>
try {
result = await verify(input)
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
result = { ok: false, error: { code: "unknown", message: "Your code did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
// A refusal about the code empties the boxes for another try; anything else keeps what was typed, to send again.
if (result.error.field === "code") {
setError(result.error.message)
if (mode === "code") setCode("")
} else setRefusal(result.error.message)
return focusNext("field")
}
setDone(true)
setSaid(
`Signed in with ${result.data.via === "code" ? "your authenticator app" : "a recovery code"}.${
result.data.trusted ? ` This device is trusted for ${trustDays} days.` : ""
}`
)
focusNext("done")
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal(null)
if (mode === "code") {
if (code.length < 6) {
setError("Enter all six digits.")
return focusNext("field")
}
return void check({ code, trust })
}
if (!recovery.trim()) {
setError("Enter a recovery code.")
return focusNext("field")
}
void check({ recovery, trust })
}
function switchTo(next: "code" | "recovery") {
setMode(next)
setError(null)
setRefusal(null)
focusNext("field")
}
const hint = `${uid}-hint`
const errorId = `${uid}-error`
const described = [hint, error ? errorId : ""].filter(Boolean).join(" ")
return (
<form noValidate onSubmit={submit} aria-label="Two-factor check" className="flex flex-col gap-5">
{mode === "code" ? (
<div className="flex flex-col gap-2">
<FieldLabel htmlFor={`${uid}-code`}>
Code from your authenticator app <span className="font-normal text-muted-foreground">(required)</span>
</FieldLabel>
<InputOTP
id={`${uid}-code`}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
// Authenticator apps show a code as "482 913" or "482-913": a paste keeps only its digits.
pasteTransformer={(pasted) => pasted.replace(/\D/g, "")}
value={code}
disabled={pending || done}
required
autoComplete="one-time-code"
aria-invalid={error ? true : undefined}
aria-describedby={described}
onChange={(value) => {
setCode(value)
setError(null)
}}
onComplete={(value: string) => void check({ code: value, trust })}
>
{/* The boxes draw the refusal themselves: the kit's slots read aria-invalid on each box. */}
{[
[0, 1, 2],
[3, 4, 5],
].map((indexes, group) => (
<React.Fragment key={group}>
{group ? <InputOTPSeparator /> : null}
<InputOTPGroup>
{indexes.map((index) => (
<InputOTPSlot
key={index}
index={index}
aria-invalid={error ? true : undefined}
className="size-10 text-base"
/>
))}
</InputOTPGroup>
</React.Fragment>
))}
</InputOTP>
<FieldDescription id={hint}>
For {maskedEmail}. Pasting the code fills every box; the sixth digit sends it.
</FieldDescription>
</div>
) : (
<div className="flex flex-col gap-2">
<FieldLabel htmlFor={`${uid}-recovery`}>
Recovery code <span className="font-normal text-muted-foreground">(required)</span>
</FieldLabel>
<Input
id={`${uid}-recovery`}
value={recovery}
required
autoComplete="off"
spellCheck={false}
placeholder="4f8k2-9dq7m"
className="max-w-56 font-mono"
disabled={pending || done}
aria-invalid={error ? true : undefined}
aria-describedby={described}
onChange={(event) => {
setRecovery(event.target.value)
setError(null)
}}
/>
<FieldDescription id={hint}>
One of the codes you saved when you set up two-factor. Each works once.
</FieldDescription>
</div>
)}
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
{refusal ? (
<Callout variant="danger" role="alert" title="The code was not checked">
{refusal}
</Callout>
) : null}
<div className="flex items-center gap-2.5">
<Checkbox
id={`${uid}-trust`}
checked={trust}
disabled={done}
onCheckedChange={(checked) => setTrust(checked === true)}
/>
<Label htmlFor={`${uid}-trust`} className="font-normal text-muted-foreground">
Trust this device for {trustDays} days
</Label>
</div>
<div className="flex min-h-12 flex-wrap items-center gap-3 border-t pt-4">
{done ? (
// What replaced the button that was pressed, so the focus has somewhere to be; the status line says the rest.
<span ref={confirmed} tabIndex={-1} className="flex items-center gap-1.5 text-sm text-success outline-none">
<CircleCheckIcon aria-hidden="true" className="size-4" />
Verified
</span>
) : (
<>
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
Verify
</Button>
<Button
type="button"
variant="link"
className="h-auto p-0"
onClick={() => switchTo(mode === "code" ? "recovery" : "code")}
>
{mode === "code" ? "Use a recovery code instead" : "Use the code from your app"}
</Button>
</>
)}
</div>
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</form>
)
}/**
* What `sign-in-forms-05` reads and checks: who is being asked for a second
* factor — the workspace owner in `db.members`, their address masked — and
* `verifySecondFactor`, which the section's server function calls. A code
* goes to the auth adapter's `verifyCode` through the kit's
* `createAuthActions`; a recovery code is checked for its shape here. Wrong
* codes are counted on the server, per member, so a page cannot reset the
* count: after the fifth, only a recovery code will do — until a quarter of an
* hour has passed since the last wrong one, so that nobody, least of all a
* stranger trying codes, can lock the code out for good. The count lives in
* this module, process-local, the way the mock adapter keeps its session; a
* real app keeps it in its store, per member.
*
* Selectors and server-side checks, reading `db` inside functions. The island
* takes plain props and never imports this module.
*/
import { createAuthActions, mockAuthAdapter, type AuthResult } from "@/lib/auth-adapter"
import { db } from "@/lib/sample-data"
export type { AuthResult }
/** How many wrong codes are allowed before only a recovery code will do. */
export const MAX_ATTEMPTS = 5
/** How long wrong codes are remembered after the last one, in minutes. */
export const LOCKOUT_MINUTES = 15
/** How long a trusted device skips this step, in days. */
export const TRUST_DAYS = 30
/** Wrong codes so far, by member id, and when the last one came. */
const attempts = new Map<string, { count: number; last: number }>()
/**
* The wrong codes a member has left in the window. The clock here is the
* server's own — elapsed time, which `REFERENCE_DATE`, a fixed day for the
* sample data, cannot measure.
*/
function wrongSoFar(memberId: string): number {
const entry = attempts.get(memberId)
if (!entry) return 0
if (Date.now() - entry.last < LOCKOUT_MINUTES * 60_000) return entry.count
attempts.delete(memberId)
return 0
}
function owner() {
const member = db.members.all().find((entry) => entry.role === "owner")
if (!member) throw new Error("sign-in-forms-05 asks the owner for a second factor, and there is no owner")
return member
}
/** "sonia.keller@vibra.example" → "s•••••••••••@vibra.example": enough to recognise, not enough to collect. */
function mask(email: string): string {
const [local, domain] = email.split("@")
return `${local.charAt(0)}${"•".repeat(Math.max(local.length - 1, 3))}@${domain}`
}
/** Who is signing in, as the challenge names them. */
export function challenge(): { name: string; maskedEmail: string } {
const member = owner()
return { name: member.name, maskedEmail: mask(member.email) }
}
export type SecondFactor = { code?: string; recovery?: string; trust: boolean }
/**
* Checks a code from the authenticator app, or a recovery code instead. A
* wrong code is counted, and says how many tries are left; past the last one
* a code is refused outright and a recovery code is the way in. A good answer
* clears the count and says whether this device is now trusted.
*
* Trusting the device is answered here, not kept: this is the seam where your
* app sets a signed, http-only cookie for the member, good for the trusted
* days, which its sign-in reads to skip this step on that device.
*/
export async function verifySecondFactor(
input: SecondFactor
): Promise<AuthResult<{ trusted: boolean; via: "code" | "recovery" }>> {
// The types are erased at the action boundary: only a plain object of short
// text gets through, anything else is a slip — refused, never counted.
if (typeof input !== "object" || input === null || Array.isArray(input))
return { ok: false, error: { code: "invalid_input", message: "That is not a check this form sends." } }
const member = owner()
const used = wrongSoFar(member.id)
// Only a real true trusts the device; "yes" or 1 does not.
const trust = input.trust === true
if (input.recovery !== undefined) {
const recovery =
typeof input.recovery === "string" && input.recovery.length <= 32 ? input.recovery.trim().toLowerCase() : ""
if (!/^[a-z0-9]{5}-[a-z0-9]{5}$/.test(recovery))
return {
ok: false,
error: {
code: "invalid_code",
field: "code",
message: "A recovery code is ten letters and digits, like 4f8k2-9dq7m.",
},
}
if (recovery === "00000-00000")
return {
ok: false,
error: {
code: "invalid_code",
field: "code",
message: "That recovery code has been used. Each one works once.",
},
}
attempts.delete(member.id)
return { ok: true, data: { trusted: trust, via: "recovery" } }
}
if (used >= MAX_ATTEMPTS)
return {
ok: false,
error: {
code: "rate_limited",
field: "code",
message: `Too many wrong codes. Use a recovery code, or wait ${LOCKOUT_MINUTES} minutes and try the code again.`,
},
}
const code = typeof input.code === "string" && input.code.length <= 32 ? input.code.trim() : ""
// A code in the wrong shape is a slip, not a guess: it is refused without being counted.
if (!/^\d{6}$/.test(code))
return { ok: false, error: { code: "invalid_code", field: "code", message: "Enter the six digits the app shows." } }
const form = new FormData()
form.set("code", code)
const result = await createAuthActions(mockAuthAdapter).verifyCode(form)
if (!result.ok) {
const left = MAX_ATTEMPTS - (used + 1)
attempts.set(member.id, { count: used + 1, last: Date.now() })
return {
ok: false,
error: {
...result.error,
message: left
? `${result.error.message} ${left} ${left === 1 ? "try" : "tries"} left.`
: `${result.error.message} That was the last try: use a recovery code, or wait ${LOCKOUT_MINUTES} minutes.`,
},
}
}
attempts.delete(member.id)
return { ok: true, data: { trusted: trust, via: "code" } }
}npx shadcn@latest add @vibra/sign-in-forms-05Sign-up with the password rules read out
sign-in-forms-06Creating an account: a name, a work email and a password whose five rules are ticked off and read out as it is typed, beside the trial the account starts on.
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SignUpForm } from "./sign-in-forms-06.client"
import { LEGAL, TRIAL_DAYS, createAccount, trialPlan, type NewAccount } from "./sign-in-forms-06.data"
/**
* Creating an account, in one framed card: the form — a name, a work email
* and a password with the rules it has to meet read out beside it — and,
* next to it, the trial the account starts on, with what it costs after and
* what it includes.
*
* A server component. It reads the trial plan through
* `./sign-in-forms-06.data` and hands the island `create`, a server function
* defined here that checks the password against the same rules and asks the
* auth adapter to sign the account up.
*/
export function SignInForms06() {
const titleId = React.useId()
const plan = trialPlan()
async function create(input: NewAccount) {
"use server"
return createAccount(input)
}
return (
<section
data-section="sign-in-forms-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Create your account</h2>
</CardTitle>
<CardDescription>
{TRIAL_DAYS} days of {plan.name}, and no card until you decide.
</CardDescription>
</CardHeader>
<CardContent className="grid p-0 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div className="p-(--card-spacing) md:p-8">
<SignUpForm links={LEGAL} create={create} />
</div>
<aside
aria-labelledby={`${titleId}-trial`}
className="flex flex-col gap-4 border-t bg-surface p-(--card-spacing) md:p-8 lg:border-s lg:border-t-0"
>
<h3 id={`${titleId}-trial`} className="text-sm font-semibold">
Your trial of {plan.name}
</h3>
<p className="text-sm text-pretty text-muted-foreground">
It runs until <span className="text-foreground tabular-nums">{plan.ends}</span>, then costs{" "}
<span className="text-foreground tabular-nums">
{formatCurrency(plan.monthlyCents / 100, "USD", { maximumFractionDigits: 0 })}
</span>{" "}
a month for up to {plan.seats} people — or drops to Free if you do nothing.
</p>
<ul className="flex flex-col gap-2 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<CheckIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{feature}
</li>
))}
</ul>
</aside>
</CardContent>
</Card>
</div>
</section>
)
}
export default SignInForms06"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon, CircleCheckIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"
import { Spinner } from "@/components/ui/spinner"
import type { AuthResult, NewAccount } from "./sign-in-forms-06.data"
type Key = "name" | "email" | "password" | "terms"
/**
* The five rules, as the form reads them out while the reader types: each a
* requirement that completes "Choose a password that …". The server checks the
* same five, in the same words.
*/
export function passwordRules(password: string, name: string, email: string) {
const words = [...name.toLowerCase().split(/\s+/), email.toLowerCase().split("@")[0]].filter(
(word) => word.length >= 3
)
const lower = password.toLowerCase()
return [
{ label: "Is at least 12 characters long", met: password.length >= 12 },
{ label: "Mixes upper and lower case letters", met: /[a-z]/.test(password) && /[A-Z]/.test(password) },
{ label: "Contains a number", met: /\d/.test(password) },
{ label: "Contains a symbol such as ! or #", met: /[^A-Za-z0-9\s]/.test(password) },
{
label: "Contains none of your name or email",
met: Boolean(password) && !words.some((word) => lower.includes(word)),
},
]
}
/** "a, b and c": the rules still to meet, joined as a sentence says them. */
const AND = new Intl.ListFormat("en", { style: "long", type: "conjunction" })
/** The meter's tone: the whole bar takes the verdict of the count, as the kit's own strength meter does. */
const meterTone = (met: number, of: number) => (met === of ? "bg-success" : met >= 3 ? "bg-warning" : "bg-danger")
export type SignUpFormProps = {
links: { terms: string; privacy: string; signIn: string }
create: (input: NewAccount) => Promise<AuthResult<{ name: string; email: string; plan: string; ends: string }>>
}
/**
* Creating an account: a name, a work email and a password, with the rules
* the password has to meet listed under it and ticked off as it is typed.
* The list describes the field, so a screen reader hears the rules with it,
* and how many are met is announced as the count changes — not on every key.
* The server checks the rules again and asks the adapter; a refusal lands
* under the field it is about with the focus on it, a created account in the
* status line.
*/
export function SignUpForm({ links, create }: SignUpFormProps) {
const uid = React.useId()
const id = (key: string) => `${uid}-${key}`
const [values, setValues] = React.useState({ name: "", email: "", password: "" })
const [terms, setTerms] = React.useState(false)
const [error, setError] = React.useState<{ field: Key; message: string } | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [created, setCreated] = React.useState(false)
const [said, setSaid] = React.useState("")
// The focus goes to a refused field once the render that marks it is on the
// screen — aria-invalid set, the message under it and named by it — so a
// screen reader reads the field with its error rather than before it. The
// count makes a second refusal of the same field a request of its own.
const [focusRequest, setFocusRequest] = React.useState<{ field: string; n: number } | null>(null)
React.useEffect(() => {
if (focusRequest) document.getElementById(`${uid}-${focusRequest.field}`)?.focus()
}, [focusRequest, uid])
const rules = passwordRules(values.password, values.name, values.email)
const met = rules.filter((rule) => rule.met).length
function refuse(field: Key, message: string) {
setError({ field, message })
setFocusRequest((last) => ({ field, n: (last?.n ?? 0) + 1 }))
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal(null)
if (!values.name.trim()) return refuse("name", "Enter your name.")
if (!isEmail(values.email.trim())) return refuse("email", "Enter your work email, like name@example.com.")
const unmet = rules.filter((rule) => !rule.met)
if (unmet.length)
return refuse("password", `Choose a password that ${AND.format(unmet.map((rule) => rule.label.toLowerCase()))}.`)
if (!terms) return refuse("terms", "Agree to the terms and the privacy notice to create an account.")
setError(null)
setPending(true)
let result: AuthResult<{ name: string; email: string; plan: string; ends: string }>
try {
result = await create({ ...values, terms })
} catch {
// The call never arrived — a dropped connection, or a page older than the server.
result = { ok: false, error: { code: "unknown", message: "Your details did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
const field = result.error.field
if (field === "name" || field === "email" || field === "password") return refuse(field, result.error.message)
return setRefusal(result.error.message)
}
setCreated(true)
setSaid(
`Account created for ${result.data.name}. Your ${result.data.plan} trial runs until ${result.data.ends}; a message to ${result.data.email} confirms the address.`
)
}
const change = (key: "name" | "email" | "password") => (event: React.ChangeEvent<HTMLInputElement>) => {
setValues((current) => ({ ...current, [key]: event.target.value }))
if (error?.field === key) setError(null)
}
const describe = (key: Key, extra?: string) =>
[extra, error?.field === key ? id(`${key}-error`) : ""].filter(Boolean).join(" ") || undefined
const message = (key: Key) =>
error?.field === key ? <FieldError id={id(`${key}-error`)}>{error.message}</FieldError> : null
const required = <span className="font-normal text-muted-foreground">(required)</span>
return (
<form noValidate onSubmit={submit} aria-label="Create your account" className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor={id("name")}>Full name {required}</FieldLabel>
<Input
id={id("name")}
autoComplete="name"
required
value={values.name}
aria-invalid={error?.field === "name" || undefined}
aria-describedby={describe("name")}
onChange={change("name")}
/>
{message("name")}
</Field>
<Field>
<FieldLabel htmlFor={id("email")}>Work email {required}</FieldLabel>
<Input
id={id("email")}
type="email"
autoComplete="email"
placeholder="name@example.com"
required
value={values.email}
aria-invalid={error?.field === "email" || undefined}
aria-describedby={describe("email")}
onChange={change("email")}
/>
{message("email")}
</Field>
</div>
<Field>
<FieldLabel htmlFor={id("password")}>Password {required}</FieldLabel>
<PasswordInput
id={id("password")}
autoComplete="new-password"
required
value={values.password}
aria-invalid={error?.field === "password" || undefined}
aria-describedby={describe("password", id("rules"))}
onChange={change("password")}
/>
{message("password")}
<div className="flex flex-col gap-2 pt-1">
{/* The strength meter, drawn from the five rules. It is decoration: the count beside the list is what is
said, and the list is read with the field. */}
<div aria-hidden="true" data-slot="password-meter" className="grid grid-cols-5 gap-1">
{rules.map((rule, index) => (
<span
key={rule.label}
data-met={index < met || undefined}
className={cn("h-1 rounded-full", index < met ? meterTone(met, rules.length) : "bg-muted")}
/>
))}
</div>
<p className="flex items-baseline justify-between gap-3 text-xs text-muted-foreground">
<span>Choose a password that</span>
<span aria-live="polite" className="tabular-nums">
{met} of {rules.length} met
</span>
</p>
<ul id={id("rules")} className="grid gap-1.5 sm:grid-cols-2">
{rules.map((rule) => (
<li
key={rule.label}
className={cn(
"flex items-center gap-2 text-sm",
rule.met ? "text-foreground" : "text-muted-foreground"
)}
>
{rule.met ? (
<CheckIcon aria-hidden="true" className="size-4 shrink-0 text-success" />
) : (
<CircleIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/60" />
)}
{rule.label}
<span className="sr-only">{rule.met ? ": met." : ": not met yet."}</span>
</li>
))}
</ul>
</div>
</Field>
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2.5">
<Checkbox
id={id("terms")}
className="mt-0.5"
checked={terms}
required
aria-invalid={error?.field === "terms" || undefined}
aria-describedby={describe("terms")}
onCheckedChange={(checked) => {
setTerms(checked === true)
if (error?.field === "terms") setError(null)
}}
/>
<Label htmlFor={id("terms")} className="leading-5 font-normal text-muted-foreground">
I agree to the terms and the privacy notice (required)
</Label>
</div>
<p className="ps-6.5 text-xs text-muted-foreground">
Read the{" "}
<Link href={links.terms} className="text-foreground underline underline-offset-4">
terms
</Link>{" "}
and the{" "}
<Link href={links.privacy} className="text-foreground underline underline-offset-4">
privacy notice
</Link>
.
</p>
{message("terms")}
</div>
{refusal ? (
<Callout variant="danger" role="alert" title="No account was created">
{refusal}
</Callout>
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t pt-4">
<Button type="submit" disabled={pending || created} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
Create account
</Button>
<p className="text-sm text-muted-foreground">
Already have one?{" "}
<Link href={links.signIn} className="text-foreground underline underline-offset-4">
Sign in
</Link>
</p>
</div>
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</form>
)
}/**
* What `sign-in-forms-06` reads and checks: the plan a new account tries —
* the one `db.plans` marks as most popular — with its price, its seats and
* what else it includes, and `createAccount`, which the section's server
* function calls. It checks the password against the same five rules the form
* reads out, then hands the account to the auth adapter's sign-up through the
* kit's `createAuthActions`, which refuses an address already registered.
*
* Selectors and server-side checks, reading `db` inside functions. The island
* takes plain props and never imports this module.
*/
import { createAuthActions, mockAuthAdapter, type AuthResult } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"
export type { AuthResult }
/** How long the trial runs, in days. */
export const TRIAL_DAYS = 14
/** Where the terms and the privacy notice live. */
export const LEGAL = { terms: "/legal/terms", privacy: "/legal/privacy", signIn: "/sign-in" }
export type TrialPlan = { name: string; monthlyCents: number; seats: number; features: string[]; ends: string }
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** The plan a trial starts on: the one marked most popular, with the day a trial begun today ends. */
export function trialPlan(): TrialPlan {
const plans = db.plans.all()
const plan = plans.find((entry) => entry.popular) ?? plans[0]
return {
name: plan.name,
monthlyCents: plan.priceMonthlyCents,
seats: plan.seatsIncluded,
features: plan.features.filter((feature) => !/\bseats?\b/i.test(feature)),
ends: DAY.format(new Date(REFERENCE_DATE.getTime() + TRIAL_DAYS * 86_400_000)),
}
}
/**
* The five rules a password has to meet, as the server checks them, in the
* words the form reads them out in: each one a requirement that completes
* "Choose a password that …". Answers with the ones not met yet.
*/
export function unmetRules(password: string, name: string, email: string): string[] {
const words = [...name.toLowerCase().split(/\s+/), email.toLowerCase().split("@")[0]].filter(
(word) => word.length >= 3
)
const lower = password.toLowerCase()
return [
password.length >= 12 ? null : "is at least 12 characters long",
/[a-z]/.test(password) && /[A-Z]/.test(password) ? null : "mixes upper and lower case letters",
/\d/.test(password) ? null : "contains a number",
/[^A-Za-z0-9\s]/.test(password) ? null : "contains a symbol such as ! or #",
password && !words.some((word) => lower.includes(word)) ? null : "contains none of your name or email",
].filter((rule): rule is string => rule !== null)
}
/** "a, b and c": the rules still to meet, joined as a sentence says them. */
const AND = new Intl.ListFormat("en", { style: "long", type: "conjunction" })
export type NewAccount = { name: string; email: string; password: string; terms: boolean }
/** The longest name, address (RFC 5321) and password the form takes. */
const MAX_NAME = 100
const MAX_EMAIL = 254
const MAX_PASSWORD = 256
/**
* Creates the account after checking every field again: a name, an address,
* a password that meets all five rules, and the terms agreed to. The adapter
* decides the rest — an address already registered is refused on the email
* field. Answers with who the account is for and when the trial ends.
*/
export async function createAccount(
input: NewAccount
): Promise<AuthResult<{ name: string; email: string; plan: string; ends: string }>> {
// The types are erased at the action boundary: only a plain object of text
// of a bounded length gets through, and an address longer than any real one
// is refused before a pattern reads it.
if (typeof input !== "object" || input === null || Array.isArray(input))
return { ok: false, error: { code: "invalid_input", message: "That is not a sign-up this form sends." } }
const name = typeof input.name === "string" ? input.name.trim() : ""
if (!name) return { ok: false, error: { code: "invalid_input", field: "name", message: "Enter your name." } }
if (name.length > MAX_NAME)
return {
ok: false,
error: { code: "invalid_input", field: "name", message: `Keep your name to ${MAX_NAME} characters.` },
}
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : ""
if (email.length > MAX_EMAIL || !isEmail(email))
return {
ok: false,
error: { code: "invalid_input", field: "email", message: "Enter your work email, like name@example.com." },
}
if (typeof input.password !== "string" || input.password.length > MAX_PASSWORD)
return {
ok: false,
error: {
code: "weak_password",
field: "password",
message: `Choose a password of up to ${MAX_PASSWORD} characters.`,
},
}
const unmet = unmetRules(input.password, name, email)
if (unmet.length)
return {
ok: false,
error: { code: "weak_password", field: "password", message: `Choose a password that ${AND.format(unmet)}.` },
}
if (input.terms !== true)
return {
ok: false,
error: { code: "invalid_input", message: "Agree to the terms and the privacy notice to create an account." },
}
const form = new FormData()
form.set("name", name)
form.set("email", email)
form.set("password", input.password)
const result = await createAuthActions(mockAuthAdapter).signUp(form)
if (!result.ok) return result
const plan = trialPlan()
return { ok: true, data: { name, email, plan: plan.name, ends: plan.ends } }
}npx shadcn@latest add @vibra/sign-in-forms-06