Blocksblock
OAuth consent
Consent screen for a third-party app requesting scopes on the user's account.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/oauth-consentUsage
"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
"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 }