Extends the org-import env preflight so a template can declare an
alternative: satisfy ANY one member to pass. Motivated by the
Claude-family node case where either ANTHROPIC_API_KEY or
CLAUDE_CODE_OAUTH_TOKEN unlocks the agent — forcing both was wrong.
Server (workspace-server):
- New EnvRequirement union type with custom YAML + JSON
(un)marshaling. Accepts scalar (strict) or {any_of: [...]} in
both on-disk org.yaml and inline POST /org/import bodies.
- collectOrgEnv now returns []EnvRequirement. Dedups groups by
sorted-member signature. "Strict wins" pruning drops any-of
groups that mention a name already declared strictly (same
tier and cross-tier).
- Import preflight uses EnvRequirement.IsSatisfied — scalar =
exact match, group = any member present.
- Empty any_of: [] rejected at parse time (never-satisfiable).
- 14 handler tests (6 updated for the union shape, 8 new
covering any-of satisfaction, dedup, strict-dominates-group,
cross-tier pruning, invalid-member filtering, YAML round-trip,
and empty-any-of rejection).
Canvas:
- EnvRequirement = string | {any_of: string[]} with envReqMembers,
envReqSatisfied, envReqKey helpers.
- OrgImportPreflightModal renders strict rows and any-of groups
via a new AnyOfEnvGroup sub-component: "Configure any one"
banner, per-member input, ✓-satisfied indicator, and dimmed
siblings once any member is configured so the user can still
switch providers.
- TemplatePalette.OrgTemplate.required_env / recommended_env
retyped to EnvRequirement[]; passthrough to the modal
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { createSecret } from "@/lib/api/secrets";
|
|
|
|
/**
|
|
* One entry from the server's preflight `required_env` / `recommended_env`.
|
|
*
|
|
* - A plain string is a STRICT requirement: that exact env var must be
|
|
* configured.
|
|
* - A `{any_of: [...]}` object is an OR group: at least one member
|
|
* must be configured to satisfy it. Lets a template say "either
|
|
* ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" without forcing
|
|
* both.
|
|
*
|
|
* Matches the Go `EnvRequirement` type's JSON shape (MarshalJSON in
|
|
* workspace-server/internal/handlers/org.go). The union is written so
|
|
* that a narrow check — `typeof e === "string"` — distinguishes cleanly.
|
|
*/
|
|
export type EnvRequirement = string | { any_of: string[] };
|
|
|
|
/** Flat member list for a requirement. */
|
|
export function envReqMembers(r: EnvRequirement): string[] {
|
|
return typeof r === "string" ? [r] : r.any_of;
|
|
}
|
|
|
|
/** True if any member is present in `configured`. */
|
|
export function envReqSatisfied(r: EnvRequirement, configured: Set<string>): boolean {
|
|
if (typeof r === "string") return configured.has(r);
|
|
return r.any_of.some((m) => configured.has(m));
|
|
}
|
|
|
|
/** Stable react-key / dedup key for a requirement. Sorted for groups so
|
|
* reordered-member variants still collapse to one entry. */
|
|
export function envReqKey(r: EnvRequirement): string {
|
|
if (typeof r === "string") return r;
|
|
return [...r.any_of].sort().join("|");
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
/** Display name of the org template — headline only. */
|
|
orgName: string;
|
|
/** Total workspace count so the header can read "12 workspaces". */
|
|
workspaceCount: number;
|
|
/** Env vars the server has declared MUST be set as global secrets.
|
|
* Import is disabled until every entry here is configured. Entries
|
|
* are either a single key name or an any-of group. */
|
|
requiredEnv: EnvRequirement[];
|
|
/** Env vars the server suggests — import can proceed without them,
|
|
* but the user sees them listed so they can decide. Same union
|
|
* shape as `requiredEnv`. */
|
|
recommendedEnv: EnvRequirement[];
|
|
/** Names of env vars already configured globally. Used to strike
|
|
* through entries the user has already set up in another
|
|
* session. Passed in rather than queried inside the modal so the
|
|
* parent can refresh after each save without prop-driven effects. */
|
|
configuredKeys: Set<string>;
|
|
/** Called after a successful secret save so the parent can refresh
|
|
* `configuredKeys`. */
|
|
onSecretSaved: () => void;
|
|
/** User clicked Import with all required envs satisfied. */
|
|
onProceed: () => void;
|
|
/** User dismissed the modal. Import is NOT fired. */
|
|
onCancel: () => void;
|
|
}
|
|
|
|
interface DraftEntry {
|
|
key: string;
|
|
value: string;
|
|
saving: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* OrgImportPreflightModal
|
|
* -----------------------
|
|
* Two-tier env preflight before POST /org/import:
|
|
*
|
|
* - REQUIRED section (red, blocking) — every entry MUST be configured
|
|
* globally before the Import button enables. Matches the server-
|
|
* side preflight that would 412 the import anyway.
|
|
*
|
|
* - RECOMMENDED section (yellow, non-blocking) — listed so the user
|
|
* can add them if they want the full experience, but the Import
|
|
* button stays enabled regardless.
|
|
*
|
|
* Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets)
|
|
* because org-level templates deploy shared resources. Per-workspace
|
|
* overrides still work via the Config tab on an individual node
|
|
* after import. The modal does NOT enable Import the moment a key is
|
|
* typed — only after it saves successfully (so a half-entered token
|
|
* can't proceed and then fail at container-start time instead).
|
|
*/
|
|
export function OrgImportPreflightModal({
|
|
open,
|
|
orgName,
|
|
workspaceCount,
|
|
requiredEnv,
|
|
recommendedEnv,
|
|
configuredKeys,
|
|
onSecretSaved,
|
|
onProceed,
|
|
onCancel,
|
|
}: Props) {
|
|
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
|
|
|
|
// Flatten the union-shaped requirement lists to the set of every key
|
|
// that could ever appear as an input row. Used purely to seed the
|
|
// drafts map — satisfaction semantics still read from the grouped
|
|
// EnvRequirement entries (a group can be satisfied by any one
|
|
// member).
|
|
const allMemberKeys = useMemo(() => {
|
|
const keys: string[] = [];
|
|
for (const r of requiredEnv) keys.push(...envReqMembers(r));
|
|
for (const r of recommendedEnv) keys.push(...envReqMembers(r));
|
|
return keys;
|
|
}, [requiredEnv, recommendedEnv]);
|
|
|
|
// Seed a draft entry per declared key the first time the modal
|
|
// opens. Entries persist across `configuredKeys` changes so a mid-
|
|
// save recheck doesn't wipe what the user typed.
|
|
//
|
|
// Dep: derive a STABLE string from the env-name lists rather than
|
|
// the array refs themselves. The parent computes
|
|
// `preflight.org.required_env ?? []`, which produces a fresh []
|
|
// identity on every re-render (e.g. when refreshConfiguredKeys
|
|
// bumps state); depending on the array refs would re-fire the
|
|
// effect on every parent render and mask any future edit that
|
|
// drops the `if (!next[k])` guard as a silent input-reset bug.
|
|
const envKeysSignature = useMemo(
|
|
() => [...allMemberKeys].sort().join("|"),
|
|
[allMemberKeys],
|
|
);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setDrafts((prev) => {
|
|
const next = { ...prev };
|
|
for (const k of allMemberKeys) {
|
|
if (!next[k]) {
|
|
next[k] = { key: k, value: "", saving: false, error: null };
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, envKeysSignature]);
|
|
|
|
const missingRequired = useMemo(
|
|
() => requiredEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
|
|
[requiredEnv, configuredKeys],
|
|
);
|
|
const missingRecommended = useMemo(
|
|
() => recommendedEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
|
|
[recommendedEnv, configuredKeys],
|
|
);
|
|
const canProceed = missingRequired.length === 0;
|
|
|
|
const saveOne = useCallback(
|
|
async (key: string) => {
|
|
// Functional setter throughout so two near-simultaneous saves
|
|
// don't have the second one's call see a stale snapshot captured
|
|
// before the first save's setState landed. Read the current
|
|
// value AND write the `saving` flag in a single transition
|
|
// rather than reading from closure-scoped `drafts`.
|
|
let startValue = "";
|
|
setDrafts((d) => {
|
|
const current = d[key];
|
|
if (!current || !current.value.trim()) return d;
|
|
startValue = current.value;
|
|
return { ...d, [key]: { ...current, saving: true, error: null } };
|
|
});
|
|
if (!startValue.trim()) return;
|
|
try {
|
|
await createSecret("global", key, startValue);
|
|
setDrafts((d) => ({
|
|
...d,
|
|
[key]: { ...d[key], value: "", saving: false, error: null },
|
|
}));
|
|
// Let the parent refresh configuredKeys so the strike-through
|
|
// updates and canProceed recomputes.
|
|
onSecretSaved();
|
|
} catch (e) {
|
|
setDrafts((d) => ({
|
|
...d,
|
|
[key]: {
|
|
...d[key],
|
|
saving: false,
|
|
error: e instanceof Error ? e.message : "Save failed",
|
|
},
|
|
}));
|
|
}
|
|
},
|
|
[onSecretSaved],
|
|
);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="org-preflight-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
|
onClick={onCancel}
|
|
>
|
|
<div
|
|
className="w-[560px] max-h-[85vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<header className="px-5 py-4 border-b border-zinc-800">
|
|
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
|
|
Deploy {orgName}
|
|
</h2>
|
|
<p className="mt-0.5 text-[11px] text-zinc-500">
|
|
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
|
Review the credentials needed before import.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="p-5 space-y-5">
|
|
{requiredEnv.length > 0 && (
|
|
<EnvList
|
|
tone="required"
|
|
title="Required"
|
|
subtitle="Import is blocked until every key below is saved globally."
|
|
entries={requiredEnv}
|
|
configuredKeys={configuredKeys}
|
|
drafts={drafts}
|
|
onChange={(key, value) =>
|
|
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
|
}
|
|
onSave={saveOne}
|
|
/>
|
|
)}
|
|
{recommendedEnv.length > 0 && (
|
|
<EnvList
|
|
tone="recommended"
|
|
title="Recommended"
|
|
subtitle="Not required, but some features degrade without them. Add them now for the best experience."
|
|
entries={recommendedEnv}
|
|
configuredKeys={configuredKeys}
|
|
drafts={drafts}
|
|
onChange={(key, value) =>
|
|
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
|
}
|
|
onSave={saveOne}
|
|
/>
|
|
)}
|
|
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
|
|
<p className="text-[12px] text-zinc-400">
|
|
No additional credentials required for this template.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{missingRecommended.length > 0 && canProceed && (
|
|
<span className="text-[10px] text-amber-400/90">
|
|
{missingRecommended.length} recommended key
|
|
{missingRecommended.length === 1 ? "" : "s"} still unset
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onProceed}
|
|
disabled={!canProceed}
|
|
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EnvListProps {
|
|
tone: "required" | "recommended";
|
|
title: string;
|
|
subtitle: string;
|
|
entries: EnvRequirement[];
|
|
configuredKeys: Set<string>;
|
|
drafts: Record<string, DraftEntry>;
|
|
onChange: (key: string, value: string) => void;
|
|
onSave: (key: string) => void;
|
|
}
|
|
|
|
function EnvList({
|
|
tone,
|
|
title,
|
|
subtitle,
|
|
entries,
|
|
configuredKeys,
|
|
drafts,
|
|
onChange,
|
|
onSave,
|
|
}: EnvListProps) {
|
|
const accent =
|
|
tone === "required"
|
|
? "border-red-800/60 bg-red-950/20"
|
|
: "border-amber-800/50 bg-amber-950/15";
|
|
const headerColor =
|
|
tone === "required" ? "text-red-300" : "text-amber-300";
|
|
|
|
return (
|
|
<div className={`rounded-lg border ${accent} p-3`}>
|
|
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
|
|
{title}
|
|
</h3>
|
|
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
|
|
<ul className="space-y-2">
|
|
{entries.map((entry) =>
|
|
typeof entry === "string" ? (
|
|
<StrictEnvRow
|
|
key={envReqKey(entry)}
|
|
envKey={entry}
|
|
configured={configuredKeys.has(entry)}
|
|
draft={drafts[entry]}
|
|
onChange={onChange}
|
|
onSave={onSave}
|
|
/>
|
|
) : (
|
|
<AnyOfEnvGroup
|
|
key={envReqKey(entry)}
|
|
members={entry.any_of}
|
|
configuredKeys={configuredKeys}
|
|
drafts={drafts}
|
|
onChange={onChange}
|
|
onSave={onSave}
|
|
/>
|
|
),
|
|
)}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface StrictEnvRowProps {
|
|
envKey: string;
|
|
configured: boolean;
|
|
draft: DraftEntry | undefined;
|
|
onChange: (key: string, value: string) => void;
|
|
onSave: (key: string) => void;
|
|
}
|
|
|
|
function StrictEnvRow({
|
|
envKey,
|
|
configured,
|
|
draft: d,
|
|
onChange,
|
|
onSave,
|
|
}: StrictEnvRowProps) {
|
|
return (
|
|
<li className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5">
|
|
<code
|
|
className={`text-[11px] font-mono flex-1 ${
|
|
configured ? "text-zinc-500 line-through" : "text-zinc-200"
|
|
}`}
|
|
>
|
|
{envKey}
|
|
</code>
|
|
{configured ? (
|
|
<span className="text-[10px] text-emerald-400">✓ set</span>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="password"
|
|
aria-label={`Value for ${envKey}`}
|
|
placeholder="paste value"
|
|
value={d?.value ?? ""}
|
|
onChange={(e) => onChange(envKey, e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onSave(envKey);
|
|
}
|
|
}}
|
|
disabled={d?.saving}
|
|
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSave(envKey)}
|
|
disabled={d?.saving || !d?.value.trim()}
|
|
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{d?.saving ? "…" : "Save"}
|
|
</button>
|
|
</>
|
|
)}
|
|
{d?.error && (
|
|
<span className="text-[9px] text-red-400 basis-full pl-1">
|
|
{d.error}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
interface AnyOfEnvGroupProps {
|
|
members: string[];
|
|
configuredKeys: Set<string>;
|
|
drafts: Record<string, DraftEntry>;
|
|
onChange: (key: string, value: string) => void;
|
|
onSave: (key: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Renders an OR group: the user only needs to configure ONE of the
|
|
* members to satisfy the requirement. Once any member is configured
|
|
* the group shows a green banner identifying the satisfying key; the
|
|
* other inputs remain visible but muted so the user can still switch
|
|
* providers if they want (uncommon but cheap to support).
|
|
*/
|
|
function AnyOfEnvGroup({
|
|
members,
|
|
configuredKeys,
|
|
drafts,
|
|
onChange,
|
|
onSave,
|
|
}: AnyOfEnvGroupProps) {
|
|
const satisfiedBy = members.find((m) => configuredKeys.has(m));
|
|
return (
|
|
<li className="rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-2">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-[10px] uppercase tracking-wide text-zinc-400">
|
|
Configure any one
|
|
</span>
|
|
{satisfiedBy && (
|
|
<span className="text-[10px] text-emerald-400">
|
|
✓ using <code className="font-mono">{satisfiedBy}</code>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<ul className="space-y-1.5">
|
|
{members.map((m) => {
|
|
const isConfigured = configuredKeys.has(m);
|
|
const d = drafts[m];
|
|
const dimmed = !!satisfiedBy && !isConfigured;
|
|
return (
|
|
<li
|
|
key={m}
|
|
className={`flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1 ${
|
|
dimmed ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
<code
|
|
className={`text-[11px] font-mono flex-1 ${
|
|
isConfigured ? "text-zinc-500 line-through" : "text-zinc-200"
|
|
}`}
|
|
>
|
|
{m}
|
|
</code>
|
|
{isConfigured ? (
|
|
<span className="text-[10px] text-emerald-400">✓ set</span>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="password"
|
|
aria-label={`Value for ${m}`}
|
|
placeholder="paste value"
|
|
value={d?.value ?? ""}
|
|
onChange={(e) => onChange(m, e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onSave(m);
|
|
}
|
|
}}
|
|
disabled={d?.saving}
|
|
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSave(m)}
|
|
disabled={d?.saving || !d?.value.trim()}
|
|
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{d?.saving ? "…" : "Save"}
|
|
</button>
|
|
</>
|
|
)}
|
|
{d?.error && (
|
|
<span className="text-[9px] text-red-400 basis-full pl-1">
|
|
{d.error}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</li>
|
|
);
|
|
}
|