Particular.Tech
Blocksblock

Sign in

Email and password, magic link, and OAuth sign-in form.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/sign-in
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Usage

sign-in-demo.tsx
"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

blocks/sign-in/sign-in-form.tsx
"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 }