BYOK Setup
A bring-your-own-key card — configurable provider slots, a masked field that cleans what you paste and checks the shape per provider, a cancellable live test with latency or a reason, a storage disclaimer that never leaves, and removal behind a confirm.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/byok-setup.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ByokSetup" card (lucide-react icons,
shadcn Button / Badge / AlertDialog) that takes a user's own provider API key:
pick a slot, paste the key, prove it works, say where it will live, and let it
be replaced or removed.
Contract
- forwardRef<HTMLFormElement>, extends React.FormHTMLAttributes minus
onSubmit / onChange / children / title. The root is a real <form> so Enter
in the field submits.
- providers?: ByokProvider[] — the slots this deployment accepts. Default to a
four-slot list. ByokProvider = { id, name, keyPrefix?, pattern?: RegExp,
minLength?, placeholder?, formatHint?, consoleUrl?, consoleLabel?,
note?: ReactNode }.
- providerId? / defaultProviderId? / onProviderChange? — controlled or
uncontrolled slot selection.
- defaultKey?: string — initial draft (a restored draft, or a demo).
onKeyChange?: (key: string) => void observes it. There is deliberately NO
controlled `key` prop: the plaintext stays inside the component, so an app
does not end up with a second copy of the secret in its own store.
- connection? / defaultConnection?: ByokConnection | null =
{ providerId, keyHint, addedAtLabel?, lastVerifiedLabel? }. The hint is a
mask like "sk-pro…4f9c"; the real stored secret is never a prop.
- test?: ByokTestState = { phase: "idle" } | { phase: "testing" } |
{ phase: "done"; result: ByokTestResult } — pass it to own the panel,
leave it off and the component runs the lifecycle itself.
- onTest?: (input: { providerId, key: string | null, signal?: AbortSignal })
=> ByokTestResult | Promise<ByokTestResult>. key === null means "the key you
already stored" — after a save this component has no plaintext to hand over.
ByokTestResult = { ok: true; latencyMs?; detail? } |
{ ok: false; reason: string; code?: "auth" | "network" | "quota" | "region"
| "timeout" | "unknown"; hint? }.
- onTestCancel?, onSave?: ({providerId, key}) => void | Promise<void>,
onRemove?: (providerId) => void | Promise<void>.
- requireTestBeforeSave?: boolean (default true), testTimeout?: number
(default 15000, 0 disables), storageScope?: "device" | "server" | "session",
storageNote?: ReactNode, title?, description?, disabled?.
- Every action is an affordance only when its callback exists: no onTest, no
Test button; no onRemove, no Remove; no onSave, no Save and no "Replace key"
either (replacing with nowhere to save it is a dead end).
Behavior — slots and drafts
- Render the slots as a radiogroup of chips, not a dropdown: role="radiogroup"
with role="radio" + aria-checked children, one tab stop (roving tabIndex),
arrows move focus AND selection with wraparound, Home/End jump to the ends.
Which providers are even possible is part of the answer, so it stays visible.
- Keep ONE DRAFT PER SLOT in a record. A key belongs to the provider that
issued it, so switching chips must not carry it over — and switching back
must not have thrown it away.
Behavior — paste hygiene
- Normalise on every change: trim, strip a leading "Authorization:" and
"Bearer ", remove ALL whitespace (a key has none, and terminals wrap long
ones across lines), strip matched wrapping quotes. Consoles hand back quoted
strings and docs hand back curl snippets; sending that verbatim produces a
401 nobody can explain.
- When normalisation deleted characters before the caret, restore the caret in
a layout effect (re-map it through the same function on the prefix), so
typing in the middle of a key does not throw the cursor to the end.
- Say what was cleaned in one muted line, and remember it as "the draft that
came out of a cleaned paste" — comparing that string to the current draft
makes the note self-invalidating: no timer, no effect.
Behavior — local checking (a courtesy, never a verdict)
- Check in this order and return the FIRST specific complaint: empty, wrong
keyPrefix ("Anthropic keys start with sk-ant-"), shorter than minLength
(name the actual length — that is a truncated paste), then the full pattern
with the provider's formatHint as the message — unless another slot claims
the key, in which case say THAT ("That is a key for Anthropic, not OpenAI"),
because "an OpenAI key starts with sk-" is useless advice for a key that
starts with sk-ant-.
- Rebuild any pattern carrying /g or /y without those flags: lastIndex would
make the same string test true, then false.
- If the key matches a DIFFERENT configured slot, offer to move it there —
move, not copy, and re-select that slot. Rank candidates by prefix length,
because every sk-ant-… key is also an sk-… key; give the default OpenAI
pattern a negative lookahead (?!ant-|or-) so the mis-paste is detectable at
all.
- Hold the complaint back while someone is mid-typing: show it when the field
has not been touched yet (a prefilled draft is already committed), or once
it has been blurred or submitted — then keep validating on every keystroke.
Wire it up with aria-invalid + aria-describedby, never a tooltip.
- A failed local check blocks Test, because it can only ever waste a request.
Behavior — the test lifecycle
- One in-flight record { id, controller, timer }. Starting a test aborts the
previous one, bumps a request id, opens an AbortController, arms the timeout
and sets phase "testing".
- A single settle() applies the first answer that is still relevant: it returns
early unless the component is mounted AND the in-flight id still matches, so
a superseded, cancelled or already-timed-out request drops its answer
silently — including the rejection that aborting causes. No second guard, no
isCancelled flag.
- Timeout fires -> abort the controller and settle a
{ ok: false, code: "timeout" } verdict of your own. Fill latencyMs from a
performance.now() delta when the caller did not report one.
- Cancel is only rendered when it can actually do something: the component owns
the controller, or onTestCancel was passed.
- BIND THE VERDICT TO ONE EXACT (slot, key) PAIR. Derive a binding string each
render; when it changes, reset the panel during render (setState-in-render,
not an effect) and abort the in-flight request in an effect cleanup keyed on
the same string. Editing one character of the key expires the verdict — a
green tick above a key nobody tested is the one failure mode that matters.
The same cleanup covers unmount.
Behavior — save, replace, remove
- Save is gated: valid shape, plus a passed test when requireTestBeforeSave.
Explain the gate in visible text tied to the button with aria-describedby —
a disabled button with no reason is a dead end.
- Submitting the form (Enter) tests while the key is unproven and saves once it
is: the same order the two buttons sit in.
- One-shot lock per action (a ref, not state) so a double click cannot fire
onSave/onRemove twice; released when the promise settles — and equally when the
handler throws SYNCHRONOUSLY (a storage quota, a permission check), or the lock
never releases and the card stays pinned at "Saving…". Pending state comes
from the promise, so it is impossible for the spinner and the request to
disagree.
- On a resolved save, DROP THE PLAINTEXT: clear the draft, reset reveal /
touched / cleaned flags, and (uncontrolled) store a connection built from a
hint (first 6 + "…" + last 4). From then on the component holds a hint, never
a secret. On a rejected save keep every character on screen and show why.
- While a save is in flight the key field goes READONLY, never disabled: Enter in
that field is what started the save, and `disabled` blurs the control the user
is standing on, so a rejection hands the field back with the key intact and the
caret gone. The busy guard in the submit path does the blocking.
- Connected view: provider, "Key on file" badge, the hint, already-formatted
added / last-verified labels (never format dates in here), plus Test (with
key: null), Replace and Remove.
- Remove goes through an AlertDialog whose body names the key hint, says where
it is being deleted from, and admits what it does NOT do: this does not
revoke the key at the provider. Use a plain destructive <Button> instead of
AlertDialogAction so the dialog can stay open and pending while onRemove is
in flight, and disable Cancel meanwhile.
- After a successful remove the dialog's trigger unmounts with the connection,
so nominate the focus target yourself: preventDefault in onCloseAutoFocus
(guarded by its own ref, because Radix may restore focus after your layout
effect already ran) and focus the key field in a layout effect.
Behavior — the disclaimer
- The storage line is rendered in BOTH modes and can never be turned off, only
reworded. Ship three canned sentences keyed by storageScope — device
("this browser only, clearing site data deletes it"), server ("encrypted,
used only to sign your requests, never logged"), session ("this tab only") —
each with its own icon, and reuse the same scope to complete the sentence in
the remove dialog.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border / bg-muted /
bg-muted/40 / text-muted-foreground / border-input / ring-ring /
border-destructive/40 / bg-destructive/10 / text-destructive / bg-primary/10
for the selected chip, and var(--chart-2) for the success tick. No hex, no
rgb(), no oklch().
- Header (icon + title + description) / body / footer bar, the whole card
rounded-xl with overflow-hidden; merge the consumer's className via cn().
- The key field is a real <input type={revealed ? "text" : "password"}> with
autoComplete/autoCorrect/autoCapitalize off and spellCheck false. Masking an
EDITABLE secret is what type=password is for; a fixed-length dot mask is for
displaying one you cannot change.
- The only motion is the spinner: animate-spin with
motion-reduce:animate-none. Nothing about the flow depends on it.
- One persistent sr-only role="status" aria-live="polite" region (never
conditionally unmounted) carries testing / verdict / saving / removing /
failure; aria-busy sits on the form while a request is in flight. Format
problems stay OUT of the live region — they are already announced through
aria-invalid + aria-describedby, and announcing them per keystroke is noise.
- Rejections use role="alert" and stay in place; a failure line never replaces
the field it is about.
Customization levers
- Slots are pure config: swap `providers` for the ones you accept, drop
keyPrefix/pattern for a provider whose keys have no documented shape (the
check then only enforces minLength), or add `note` for account requirements
such as billing or a waitlist.
- The gate: requireTestBeforeSave={false} for products that would rather store
an unverified key than block onboarding — the Test button stays available.
- testTimeout: raise it for cold serverless providers, set 0 to wait forever
and let the user cancel.
- storageScope picks the disclaimer; storageNote replaces the wording for a
legal team. Keep the line.
- Failure copy: the FAILURE_HINT map is advice per class of failure and is
meant to be rewritten in your voice; `reason` stays the provider's own words.
- Chrome: title / description / the console link label are all props; drop the
header icon or the note for a denser card. The connected view's labels are
strings you format upstream, so relative time stays your call.
- To let the app own the test lifecycle (verification happens server-side after
a save, say), pass `test` and drive the three phases yourself; the rest of
the card keeps working unchanged.Concepts
- Slot-bound draft — a key belongs to the provider that issued it, so each chip keeps its own draft; switching providers never carries a key across and never throws one away.
- Paste hygiene before judgement — quotes, line breaks and a leading
Bearerare stripped on the way in (with the caret re-mapped), because the most common "invalid key" is a perfectly good key with a console's punctuation still attached. - Local check versus provider verdict — the shape check only catches what can be known for free (wrong prefix, truncated paste, wrong slot); whether the key works is a question only one live request can answer, and the two are never conflated on screen.
- Verdict bound to one key — the green tick is tied to an exact (slot, key) pair and expires the moment either changes, aborting whatever is in flight; a verdict that outlives its key is the one lie this card must not tell.
- Wrong-slot rescue — a key pasted under the wrong provider is recognised by the other slots' patterns (most specific prefix wins) and offered a one-click move rather than a generic "invalid" that leaves the user re-reading their console.
- Custody ends at Save — the plaintext is dropped the moment the save resolves, leaving a masked hint; re-testing a stored key therefore sends
key: null, and the remove dialog is explicit that deleting it here does not revoke it at the provider.
Rate Limit Meter
A live request-budget meter — a rolling-window gauge, a burst-dot timeline of recent calls, an escalating severity band, an expiry-aware forecast of when the limit lands, and a reset countdown once it does.
Data Controls
An AI privacy panel where every control saves on its own — a training consent switch with a plain-language consequence line, a retention scale that warns when it deletes nothing, an export job with progress, and a type-to-confirm deletion.