Permission consent
OAuth-style "do you allow X?" prompt. Resolves with allow or deny — a tagged response, not a boolean.
The Callable
declared once, mounted in the React treeimport { createCallable } from 'react-call'
interface Props { appName: string scopes: readonly string[]}
export const Permission = createCallable<Props, 'allow' | 'deny'>( ({ call, appName, scopes }) => ( <div role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" > <div className="w-full max-w-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-6 shadow-2xl"> <p className="text-base font-medium text-[var(--color-fg)]"> Allow <span className="text-[var(--color-accent)]">{appName}</span>{' '} to: </p> <ul className="mt-4 space-y-2"> {scopes.map((s) => ( <li key={s} className="flex items-start gap-2 text-sm text-[var(--color-fg-muted)]" > <span aria-hidden="true" className="text-[var(--color-accent)]"> ✓ </span> {s} </li> ))} </ul> <div className="mt-6 flex items-center justify-end gap-3"> <button type="button" onClick={() => call.end('deny')} className="rounded-md border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)]" > Deny </button> <button type="button" onClick={() => call.end('allow')} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]" > Allow </button> </div> </div> </div> ),)Permission.displayName = 'Permission'The Root
mounted in your app tree, onceimport { Permission } from './Permission'import { ConnectButton } from './ConnectButton'
export default function App() { return ( <> <Permission /> <ConnectButton /> </> )}The caller
anywhere in your app, imperativeimport { useState } from 'react'import { Permission } from './Permission'
export const ConnectButton = () => { const [status, setStatus] = useState<'idle' | 'connected' | 'denied'>('idle')
const handleClick = async () => { const result = await Permission.call({ appName: 'react-call demo', scopes: [ 'Read your profile', 'Read your repositories', 'Subscribe to webhook events', ], }) setStatus(result === 'allow' ? 'connected' : 'denied') }
return ( <div className="flex flex-col items-center gap-3"> <button type="button" onClick={handleClick} disabled={status === 'connected'} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)] disabled:opacity-50" > {status === 'connected' ? 'Connected' : 'Connect with GitHub'} </button> <span className="font-mono text-xs text-[var(--color-fg-subtle)]"> {status === 'idle' && '→ awaiting consent…'} {status === 'connected' && ( <span className="text-[var(--color-accent)]">→ allowed</span> )} {status === 'denied' && '→ denied'} </span> </div> )}Not every yes/no is a boolean
confirm-dialog returns a boolean, and for
“proceed or not” that is right. Consent is different: this Callable
resolves with 'allow' | 'deny'. The Response type is part of your API
— a named union documents itself at the call site (result === 'allow'
beats remembering which way true points) and leaves room to grow
('allow-once', 'manage') without rewriting every caller. Reach past
boolean when the two outcomes aren’t a pure true/false, or you expect a
third one someday.
The Response set also decides how the user can leave. There is no
backdrop-click or Esc handler here on purpose: 'allow' | 'deny' has no
“dismissed” variant, so there is deliberately no way to close the prompt
without answering. Compare item-picker, whose
T | null Response does model dismissal — so it lets you click away.
Your Response type and your escape hatches have to agree.
When to reach for it
OAuth-style consent, permission and cookie prompts, “approve these terms” flows — anywhere the outcome is a labelled decision you might store, log, or branch on, not a throwaway yes/no.