Blocksblock
Sign in
Email and password, magic link, and OAuth sign-in form.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/sign-inUsage
"use client"
import { KeyRoundIcon, LockIcon } from "lucide-react"
import { SignInForm } from "@/components/sign-in-form"
import { toast } from "@/components/ui/toast"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
export function Demo() {
return (
<div className="flex min-h-svh items-center justify-center bg-muted/30 p-6">
<div className="w-full max-w-sm rounded-2xl border bg-card p-8 shadow-sm">
<SignInForm
providers={[
{ id: "sso", label: "SAML SSO", icon: <LockIcon /> },
{ id: "passkey", label: "a passkey", icon: <KeyRoundIcon /> },
]}
onProviderSignIn={async (provider) => {
await wait(800)
toast.add({ title: `Redirecting to ${provider}…` })
}}
onPasswordSignIn={async ({ password }) => {
await wait(800)
if (password !== "particular") throw new Error("Incorrect email or password. (Hint: the demo password is “particular”.)")
toast.add({ title: "Signed in", type: "success" })
}}
onMagicLink={() => wait(800)}
forgotPasswordHref="#"
footer={
<>
No account?{" "}
<a href="#" className="font-medium text-foreground underline underline-offset-4">
Sign up
</a>
</>
}
/>
</div>
</div>
)
}Source
"use client"
import * as React from "react"
import { MailIcon } from "lucide-react"
import { cn } from "cn"
import { LogoMark } from "@/components/logo"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel, FieldSeparator } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
export type OAuthProvider = { id: string; label: string; icon?: React.ReactNode }
export type SignInFormProps = {
title?: string
description?: React.ReactNode
/** Password sign-in. Reject with an Error to show its message. */
onPasswordSignIn?: (credentials: { email: string; password: string }) => Promise<void>
/** Passwordless sign-in. Resolve once the email is sent. */
onMagicLink?: (email: string) => Promise<void>
providers?: OAuthProvider[]
onProviderSignIn?: (providerId: string) => Promise<void>
forgotPasswordHref?: string
footer?: React.ReactNode
className?: string
}
type Pending = null | "password" | "magic-link" | `provider:${string}`
function messageOf(error: unknown) {
return error instanceof Error ? error.message : "Something went wrong. Try again."
}
function SignInForm({
title = "Sign in to Particular.Tech",
description = "Welcome back. Choose how you want to continue.",
onPasswordSignIn,
onMagicLink,
providers = [],
onProviderSignIn,
forgotPasswordHref,
footer,
className,
}: SignInFormProps) {
const [mode, setMode] = React.useState<"password" | "magic-link">(onPasswordSignIn ? "password" : "magic-link")
const [pending, setPending] = React.useState<Pending>(null)
const [error, setError] = React.useState<string | null>(null)
const [sentTo, setSentTo] = React.useState<string | null>(null)
const run = async (kind: Exclude<Pending, null>, action: () => Promise<void>) => {
setPending(kind)
setError(null)
try {
await action()
} catch (caught) {
setError(messageOf(caught))
} finally {
setPending(null)
}
}
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const email = String(form.get("email") ?? "").trim()
if (mode === "password" && onPasswordSignIn) {
const password = String(form.get("password") ?? "")
void run("password", () => onPasswordSignIn({ email, password }))
} else if (onMagicLink) {
void run("magic-link", async () => {
await onMagicLink(email)
setSentTo(email)
})
}
}
if (sentTo) {
return (
<div className={cn("mx-auto flex w-full max-w-sm flex-col items-center gap-4 text-center", className)}>
<span className="flex size-12 items-center justify-center rounded-full bg-brand-subtle text-brand">
<MailIcon className="size-5" />
</span>
<h1 className="font-heading text-xl font-semibold tracking-tight">Check your email</h1>
<p className="text-sm text-muted-foreground">
We sent a sign-in link to <span className="font-medium text-foreground">{sentTo}</span>. It expires in 15 minutes.
</p>
<Button variant="ghost" size="sm" onClick={() => setSentTo(null)}>
Use a different email
</Button>
</div>
)
}
return (
<div className={cn("mx-auto flex w-full max-w-sm flex-col gap-6", className)}>
<div className="flex flex-col items-center gap-3 text-center">
<LogoMark className="size-10" />
<h1 className="font-heading text-xl font-semibold tracking-tight">{title}</h1>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{providers.length > 0 && onProviderSignIn && (
<div className="grid gap-2">
{providers.map((provider) => (
<Button
key={provider.id}
variant="outline"
size="lg"
disabled={pending !== null}
onClick={() => void run(`provider:${provider.id}`, () => onProviderSignIn(provider.id))}
>
{pending === `provider:${provider.id}` ? <Spinner data-icon="inline-start" /> : provider.icon}
Continue with {provider.label}
</Button>
))}
</div>
)}
{providers.length > 0 && onProviderSignIn && (onPasswordSignIn || onMagicLink) && (
<FieldSeparator>or</FieldSeparator>
)}
{(onPasswordSignIn || onMagicLink) && (
<form method="post" onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="sign-in-email">Email</FieldLabel>
<Input id="sign-in-email" name="email" type="email" autoComplete="email" placeholder="you@company.com" required />
</Field>
{mode === "password" && (
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="sign-in-password">Password</FieldLabel>
{forgotPasswordHref && (
<a href={forgotPasswordHref} className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
Forgot password?
</a>
)}
</div>
<Input id="sign-in-password" name="password" type="password" autoComplete="current-password" required />
</Field>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" size="lg" disabled={pending !== null}>
{(pending === "password" || pending === "magic-link") && <Spinner data-icon="inline-start" />}
{mode === "password" ? "Sign in" : "Email me a sign-in link"}
</Button>
{onPasswordSignIn && onMagicLink && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setError(null)
setMode(mode === "password" ? "magic-link" : "password")
}}
>
{mode === "password" ? "Sign in with a magic link instead" : "Sign in with a password instead"}
</Button>
)}
</FieldGroup>
</form>
)}
{error && !(onPasswordSignIn || onMagicLink) && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{footer && <div className="text-center text-xs text-muted-foreground">{footer}</div>}
</div>
)
}
export { SignInForm }