Replace all text-ink-soft usages across canvas components and app pages. ink-soft (#8d92a0) on dark zinc (#0e1014) yields ~2.2:1 contrast, failing WCAG 2.1 AA minimum of 4.5:1 for normal text. ink-mid (#c8c2b4) on dark zinc yields ~7.6:1 — well above AA. text-ink-mid is already the semantic token for secondary/caption text in the warm-paper light mode; the dark-mode override was the gap. 52 files, 268 replacements. No functional change beyond contrast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
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;
|
|
|
|
// Synchronous in-flight gate. A ref (not state) so two clicks
|
|
// dispatched in the SAME microtask both see the gate flip — state
|
|
// commits don't help here because setState is async. The previous
|
|
// closure-based `current.saving` gate worked under React Testing
|
|
// Library's act() flushing but failed for true microtask-level
|
|
// double-fires (programmatic clicks, dblclick events, Enter-spam
|
|
// before React commits). Set is keyed by env var name so different
|
|
// rows can save concurrently.
|
|
const inFlightRef = useRef<Set<string>>(new Set());
|
|
|
|
// Latest-drafts ref so saveOne can read the current input value
|
|
// without taking `drafts` as a useCallback dep — that dep would
|
|
// re-create saveOne on every keystroke and re-bind every Save
|
|
// button's onClick handler, churn that scales with row count.
|
|
const draftsRef = useRef(drafts);
|
|
useEffect(() => {
|
|
draftsRef.current = drafts;
|
|
}, [drafts]);
|
|
|
|
const saveOne = useCallback(
|
|
async (key: string) => {
|
|
// Microtask-safe gate: claim the slot synchronously BEFORE any
|
|
// await so a second click in the same tick bounces immediately.
|
|
if (inFlightRef.current.has(key)) return;
|
|
const current = draftsRef.current[key];
|
|
if (!current || !current.value.trim()) return;
|
|
inFlightRef.current.add(key);
|
|
|
|
const startValue = current.value;
|
|
setDrafts((d) => ({
|
|
...d,
|
|
[key]: { ...d[key], saving: true, error: null },
|
|
}));
|
|
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",
|
|
},
|
|
}));
|
|
} finally {
|
|
inFlightRef.current.delete(key);
|
|
}
|
|
},
|
|
[onSecretSaved],
|
|
);
|
|
|
|
if (!open) return null;
|
|
|
|
// Portal the dialog to document.body so it escapes any ancestor
|
|
// containing block. TemplatePalette renders this modal inside a
|
|
// sidebar whose `fixed` container plus backdrop-filter together
|
|
// re-anchor descendants' `position: fixed` to the sidebar's own
|
|
// bounds instead of the viewport — the modal ends up glued to the
|
|
// sidebar's scrollable region and only becomes visible after the
|
|
// user scrolls the sidebar. Portal dodges that class of issue
|
|
// once and for all, regardless of what future wrappers do.
|
|
//
|
|
// SSR-safe guard: `document` is undefined on the server. Since
|
|
// the modal is gated by `if (!open) return null` above, this
|
|
// effectively only runs after open flips true on the client.
|
|
if (typeof document === "undefined") return null;
|
|
|
|
return createPortal(
|
|
<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-[80vh] overflow-auto rounded-xl bg-surface-sunken border border-line shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<header className="px-5 py-4 border-b border-line">
|
|
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
|
|
Deploy {orgName}
|
|
</h2>
|
|
<p className="mt-0.5 text-[11px] text-ink-mid">
|
|
{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-ink-mid">
|
|
No additional credentials required for this template.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<footer className="px-5 py-3 border-t border-line flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{missingRecommended.length > 0 && canProceed && (
|
|
<span className="text-[10px] text-warm/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-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
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-bad" : "text-warm";
|
|
|
|
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-ink-mid">{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-surface-sunken/70 border border-line px-2 py-1.5">
|
|
<code
|
|
className={`text-[11px] font-mono flex-1 ${
|
|
configured ? "text-ink-mid line-through" : "text-ink"
|
|
}`}
|
|
>
|
|
{envKey}
|
|
</code>
|
|
{configured ? (
|
|
<span className="text-[10px] text-good">✓ 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-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSave(envKey)}
|
|
disabled={d?.saving || !d?.value.trim()}
|
|
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{d?.saving ? "…" : "Save"}
|
|
</button>
|
|
</>
|
|
)}
|
|
{d?.error && (
|
|
<span className="text-[9px] text-bad 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-line bg-surface-sunken/50 px-2.5 py-2">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-[10px] uppercase tracking-wide text-ink-mid">
|
|
Configure any one
|
|
</span>
|
|
{satisfiedBy && (
|
|
<span className="text-[10px] text-good">
|
|
✓ 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-surface-sunken/70 border border-line px-2 py-1 ${
|
|
dimmed ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
<code
|
|
className={`text-[11px] font-mono flex-1 ${
|
|
isConfigured ? "text-ink-mid line-through" : "text-ink"
|
|
}`}
|
|
>
|
|
{m}
|
|
</code>
|
|
{isConfigured ? (
|
|
<span className="text-[10px] text-good">✓ 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-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSave(m)}
|
|
disabled={d?.saving || !d?.value.trim()}
|
|
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{d?.saving ? "…" : "Save"}
|
|
</button>
|
|
</>
|
|
)}
|
|
{d?.error && (
|
|
<span className="text-[9px] text-bad basis-full pl-1">
|
|
{d.error}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</li>
|
|
);
|
|
}
|