Particular.Tech
Blocksblock

OAuth consent

Consent screen for a third-party app requesting scopes on the user's account.

Preview

Open full screen

Installation

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

Usage

oauth-consent-demo.tsx
"use client"

import { OAuthConsent } from "@/components/oauth-consent"
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-md rounded-2xl border bg-card p-8 shadow-sm">
        <OAuthConsent
          client={{ name: "Acme Deploy Bot", uri: "https://deploybot.acme.dev" }}
          account={{ name: "Ada Lovelace", email: "ada@particular.tech" }}
          scopes={[
            { name: "profile", description: "See your name and profile picture" },
            { name: "email", description: "See your email address" },
            { name: "account:read", description: "Read your Particular.Tech account", optional: true },
            { name: "env:read", description: "Read your environments and their variables", optional: true },
            { name: "offline_access", description: "Stay connected when you are not using it" },
          ]}
          onDecision={async (accept, scopes) => {
            await wait(700)
            toast.add({ title: accept ? "Access granted" : "Access denied", description: accept ? scopes.join(" ") : undefined, type: accept ? "success" : "info" })
          }}
          footer={
            <a href="#" className="underline underline-offset-4 hover:text-foreground">
              Not you? Switch account
            </a>
          }
        />
      </div>
    </div>
  )
}

Source

blocks/oauth-consent/oauth-consent.tsx
"use client"

import * as React from "react"
import { ArrowLeftRightIcon, CheckIcon, ExternalLinkIcon } from "lucide-react"
import { cn } from "cn"

import { LogoMark } from "@/components/logo"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Spinner } from "@/components/ui/spinner"

export type OAuthConsentClient = {
  name: string
  /** Homepage shown under the name. */
  uri?: string | null
  icon?: string | null
}

export type OAuthConsentScope = {
  name: string
  description: string
  /** Shown with a checkbox, ticked by default, so the user can leave it out. */
  optional?: boolean
}

export type OAuthConsentProps = {
  client: OAuthConsentClient
  scopes: OAuthConsentScope[]
  /** The account the client will act for. */
  account: { name: string; email: string; image?: string | null }
  /** Resolve once the decision is recorded; the caller usually navigates away. Reject with an Error to show its message. */
  /** `scopes` lists every required scope plus the optional ones left ticked. */
  onDecision: (accept: boolean, scopes: string[]) => Promise<void>
  /** Shown under the buttons, e.g. "Not you? Switch account". */
  footer?: React.ReactNode
  className?: string
}

function initials(name: string) {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("")
}

function hostOf(uri: string | null | undefined) {
  if (!uri) return null
  try {
    return new URL(uri).host
  } catch {
    return null
  }
}

function OAuthConsent({ client, scopes, account, onDecision, footer, className }: OAuthConsentProps) {
  const [pending, setPending] = React.useState<"accept" | "deny" | null>(null)
  const [decided, setDecided] = React.useState(false)
  const [excluded, setExcluded] = React.useState<ReadonlySet<string>>(() => new Set())
  const [error, setError] = React.useState<string | null>(null)
  const host = hostOf(client.uri)

  const decide = async (accept: boolean) => {
    setPending(accept ? "accept" : "deny")
    setError(null)
    try {
      await onDecision(accept, scopes.filter((scope) => !excluded.has(scope.name)).map((scope) => scope.name))
      setDecided(true)
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Something went wrong. Try again.")
    } finally {
      setPending(null)
    }
  }

  return (
    <div className={cn("mx-auto flex w-full max-w-md flex-col gap-6", className)}>
      <div className="flex items-center justify-center gap-3">
        <Avatar className="size-12 rounded-xl">
          {client.icon && <AvatarImage src={client.icon} alt="" />}
          <AvatarFallback className="rounded-xl">{initials(client.name)}</AvatarFallback>
        </Avatar>
        <ArrowLeftRightIcon aria-hidden className="size-4 text-muted-foreground" />
        <LogoMark className="size-12" />
      </div>

      <div className="flex flex-col gap-1 text-center">
        <h1 className="font-heading text-xl font-semibold tracking-tight text-balance">
          {client.name} wants to access your account
        </h1>
        {host && client.uri && (
          <a
            href={client.uri}
            target="_blank"
            rel="noreferrer"
            className="inline-flex items-center justify-center gap-1 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
          >
            {host}
            <ExternalLinkIcon className="size-3.5" aria-hidden />
          </a>
        )}
      </div>

      <div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
        <Avatar className="size-8">
          {account.image && <AvatarImage src={account.image} alt="" />}
          <AvatarFallback className="text-xs">{initials(account.name)}</AvatarFallback>
        </Avatar>
        <div className="min-w-0 text-sm">
          <div className="truncate font-medium">{account.name}</div>
          <div className="truncate text-muted-foreground">{account.email}</div>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <p className="text-sm font-medium">This will allow {client.name} to:</p>
        <ul className="flex flex-col gap-3">
          {scopes.map((scope) => {
            const id = `scope-${scope.name.replace(/[^A-Za-z0-9_-]/g, "-")}`
            const text = (
              <div className="flex flex-col">
                <span>{scope.description}</span>
                <code className="font-mono text-xs text-muted-foreground">{scope.name}</code>
              </div>
            )
            return (
              <li key={scope.name} className="flex gap-3 text-sm">
                {scope.optional ? (
                  <>
                    <Checkbox
                      id={id}
                      className="mt-0.5"
                      checked={!excluded.has(scope.name)}
                      disabled={pending !== null || decided}
                      onCheckedChange={(checked) =>
                        setExcluded((current) => {
                          const next = new Set(current)
                          if (checked) next.delete(scope.name)
                          else next.add(scope.name)
                          return next
                        })
                      }
                    />
                    <label htmlFor={id} className="cursor-pointer">
                      {text}
                    </label>
                  </>
                ) : (
                  <>
                    <CheckIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-brand" />
                    {text}
                  </>
                )}
              </li>
            )
          })}
        </ul>
      </div>

      {error && (
        <Alert variant="destructive">
          <AlertDescription>{error}</AlertDescription>
        </Alert>
      )}

      <div className="grid grid-cols-2 gap-2">
        <Button variant="outline" size="lg" disabled={pending !== null || decided} onClick={() => void decide(false)}>
          {pending === "deny" && <Spinner data-icon="inline-start" />}
          Deny
        </Button>
        <Button variant="brand" size="lg" disabled={pending !== null || decided} onClick={() => void decide(true)}>
          {pending === "accept" && <Spinner data-icon="inline-start" />}
          Allow
        </Button>
      </div>

      <p className="text-center text-xs text-muted-foreground">
        You can revoke access at any time from your account settings.
      </p>
      {footer && <div className="text-center text-xs text-muted-foreground">{footer}</div>}
    </div>
  )
}

export { OAuthConsent }