AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  CircleCheck,
  ExternalLink,
  Eye,
  EyeOff,
  HardDrive,
  KeyRound,
  LoaderCircle,
  Plug,
  RefreshCw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/byok-setup.json

Prompt

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 Bearer are 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.

On This Page