Skip to content

Commit 593bcb0

Browse files
committed
feat(web): point provider environment variables at a secret provider
Signed-off-by: Yordis Prieto <[email protected]>
1 parent b05724e commit 593bcb0

6 files changed

Lines changed: 308 additions & 9 deletions

File tree

‎apps/web/src/components/settings/AddProviderInstanceDialog.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon";
5555
import { AddManagedCodexAccountDialog } from "./CodexSetupSection";
5656
import { MultipleAccountsNote } from "./MultipleAccountsNote";
5757
import { ProviderEnvironmentSection } from "./ProviderInstanceCard";
58+
import { useSecretSources } from "./SecretSourceInput";
5859

5960
/** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */
6061
function slugifyLabel(value: string): string {
@@ -160,6 +161,7 @@ export function AddProviderInstanceDialog({
160161
onCreated,
161162
}: AddProviderInstanceDialogProps) {
162163
const settings = useEnvironmentSettings(environmentId);
164+
const secretSources = useSecretSources(environmentId);
163165
const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
164166
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
165167

@@ -536,6 +538,7 @@ export function AddProviderInstanceDialog({
536538
<ProviderEnvironmentSection
537539
environment={localEnvironment}
538540
onChange={setLocalEnvironment}
541+
secretSources={secretSources}
539542
/>
540543
) : null}
541544
</SettingsGroup>

‎apps/web/src/components/settings/ProviderInstanceCard.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, expect, it } from "vite-plus/test";
2-
import type { ServerProviderModel } from "@t3tools/contracts";
2+
import { SecretProviderId, type ServerProviderModel } from "@t3tools/contracts";
33

44
import {
55
deriveProviderModelsForDisplay,
6+
nextProviderEnvironmentWithFieldSecret,
67
nextProviderEnvironmentWithFieldValue,
78
} from "./ProviderInstanceCard";
89

@@ -75,6 +76,20 @@ describe("provider environment helpers", () => {
7576
).toEqual([{ name: "CURSOR_API_KEY", value: "new-key", sensitive: true }]);
7677
});
7778

79+
it("points a stored provider secret at a secret provider and drops the stored value", () => {
80+
const secret = {
81+
provider: SecretProviderId.make("personal"),
82+
reference: "op://Private/Cursor/credential",
83+
};
84+
const stored = [{ name: "CURSOR_API_KEY", value: "", sensitive: true, valueRedacted: true }];
85+
const referenced = nextProviderEnvironmentWithFieldSecret(stored, cursorApiKeyField, secret);
86+
87+
expect(referenced).toEqual([{ name: "CURSOR_API_KEY", value: secret, sensitive: true }]);
88+
expect(
89+
nextProviderEnvironmentWithFieldSecret(referenced, cursorApiKeyField, undefined),
90+
).toEqual([]);
91+
});
92+
7893
it("applies the secure field default when replacing an existing non-sensitive value", () => {
7994
expect(
8095
nextProviderEnvironmentWithFieldValue(

‎apps/web/src/components/settings/ProviderInstanceCard.tsx‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type EnvironmentId,
2727
type ProjectId,
2828
type ProviderDriverKind,
29+
type SecretReference,
2930
type ServerProvider,
3031
type ServerProviderModel,
3132
} from "@t3tools/contracts";
@@ -43,7 +44,7 @@ import { Badge } from "../ui/badge";
4344
import { Button } from "../ui/button";
4445
import { MorphIcon } from "~/components/MorphIcon";
4546
import { DraftInput } from "../ui/draft-input";
46-
import { SecretInput } from "./SecretInput";
47+
import { SecretSourceInput, type SecretSource } from "./SecretSourceInput";
4748
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4849
import { Switch } from "../ui/switch";
4950
import { stackedThreadToast, toastManager } from "../ui/toast";
@@ -98,7 +99,7 @@ const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariable
9899
type EnvironmentDraftRow = {
99100
readonly id: string;
100101
readonly name: string;
101-
readonly value: string;
102+
readonly value: ProviderInstanceEnvironmentVariable["value"];
102103
readonly sensitive: boolean;
103104
readonly valueRedacted?: boolean;
104105
};
@@ -119,6 +120,14 @@ function makeEnvironmentDraftRow(
119120
const toEnvironmentDraftRows = (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) =>
120121
environment.map(makeEnvironmentDraftRow);
121122

123+
function environmentValuesEqual(
124+
left: ProviderInstanceEnvironmentVariable["value"],
125+
right: ProviderInstanceEnvironmentVariable["value"],
126+
): boolean {
127+
if (typeof left === "string" || typeof right === "string") return left === right;
128+
return left.provider === right.provider && left.reference === right.reference;
129+
}
130+
122131
function providerEnvironmentsEqual(
123132
left: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
124133
right: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
@@ -130,7 +139,7 @@ function providerEnvironmentsEqual(
130139
return (
131140
other !== undefined &&
132141
variable.name === other.name &&
133-
variable.value === other.value &&
142+
environmentValuesEqual(variable.value, other.value) &&
134143
variable.sensitive === other.sensitive &&
135144
variable.valueRedacted === other.valueRedacted
136145
);
@@ -247,12 +256,28 @@ export function nextProviderEnvironmentWithFieldValue(
247256
return next;
248257
}
249258

259+
export function nextProviderEnvironmentWithFieldSecret(
260+
environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
261+
field: ProviderEnvironmentField,
262+
secret: SecretReference | undefined,
263+
): ReadonlyArray<ProviderInstanceEnvironmentVariable> {
264+
const others = providerEnvironmentWithoutNames(environment, new Set([field.name]));
265+
return secret === undefined
266+
? others
267+
: [...others, { name: field.name, value: secret, sensitive: true }];
268+
}
269+
250270
function ProviderEnvironmentFieldRow(props: {
251271
readonly field: ProviderEnvironmentField;
252272
readonly variable: ProviderInstanceEnvironmentVariable | undefined;
253273
readonly idPrefix: string;
254274
readonly onCommit: (field: ProviderEnvironmentField, value: string) => void;
275+
readonly onSecretChange: (
276+
field: ProviderEnvironmentField,
277+
secret: SecretReference | undefined,
278+
) => void;
255279
readonly onRemove: (field: ProviderEnvironmentField) => void;
280+
readonly secretSources: ReadonlyArray<SecretSource>;
256281
}) {
257282
const inputId = `${props.idPrefix}-environment-${props.field.name}`;
258283
const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
@@ -269,13 +294,13 @@ function ProviderEnvironmentFieldRow(props: {
269294
size="sm"
270295
className="min-w-0 flex-1"
271296
autoComplete="off"
272-
value={value}
297+
value={typeof value === "string" ? value : ""}
273298
onCommit={(next) => props.onCommit(props.field, next)}
274299
placeholder={props.field.placeholder}
275300
spellCheck={false}
276301
/>
277302
) : (
278-
<SecretInput
303+
<SecretSourceInput
279304
id={inputId}
280305
size="sm"
281306
className="min-w-0 flex-1"
@@ -284,6 +309,9 @@ function ProviderEnvironmentFieldRow(props: {
284309
placeholder={props.field.placeholder}
285310
configured={props.variable?.valueRedacted === true}
286311
spellCheck={false}
312+
aria-label={props.field.label}
313+
sources={props.secretSources}
314+
onSecretChange={(secret) => props.onSecretChange(props.field, secret)}
287315
/>
288316
)}
289317
{props.variable ? (
@@ -306,6 +334,7 @@ function ProviderEnvironmentFieldRow(props: {
306334
export function ProviderEnvironmentSection(props: {
307335
readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
308336
readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
337+
readonly secretSources?: ReadonlyArray<SecretSource>;
309338
}) {
310339
const [storedDraft, setDraft] = useState(() =>
311340
seedRowsDraft(props.environment, toEnvironmentDraftRows),
@@ -328,7 +357,7 @@ export function ProviderEnvironmentSection(props: {
328357
if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) {
329358
if (
330359
name.length > 0 ||
331-
row.value.length > 0 ||
360+
row.value !== "" ||
332361
row.sensitive !== true ||
333362
row.valueRedacted !== undefined
334363
) {
@@ -357,6 +386,16 @@ export function ProviderEnvironmentSection(props: {
357386
publishRows(nextRows);
358387
};
359388

389+
const setVariableSecret = (id: string, secret: SecretReference | undefined) => {
390+
const nextRows = rows.map((row) => {
391+
if (row.id !== id) return row;
392+
const { valueRedacted: _valueRedacted, ...rest } = row;
393+
return { ...rest, value: secret ?? "" };
394+
});
395+
setRows(nextRows);
396+
publishRows(nextRows);
397+
};
398+
360399
const removeVariable = (id: string) => {
361400
const nextRows = rows.filter((row) => row.id !== id);
362401
setRows(nextRows);
@@ -403,7 +442,7 @@ export function ProviderEnvironmentSection(props: {
403442
=
404443
</span>
405444
{variable.sensitive ? (
406-
<SecretInput
445+
<SecretSourceInput
407446
size="sm"
408447
font="mono"
409448
className="min-w-0 flex-1"
@@ -413,13 +452,19 @@ export function ProviderEnvironmentSection(props: {
413452
configured={variable.valueRedacted === true}
414453
spellCheck={false}
415454
aria-label={`Environment variable value ${index + 1}`}
455+
sources={props.secretSources ?? []}
456+
onSecretChange={(secret) => setVariableSecret(variable.id, secret)}
416457
/>
417458
) : (
418459
<DraftInput
419460
size="sm"
420461
font="mono"
421462
className="min-w-0 flex-1"
422-
value={variable.valueRedacted ? "" : variable.value}
463+
value={
464+
variable.valueRedacted || typeof variable.value !== "string"
465+
? ""
466+
: variable.value
467+
}
423468
onCommit={(value) => updateVariable(variable.id, { value })}
424469
autoComplete="off"
425470
placeholder="value"
@@ -444,6 +489,7 @@ export function ProviderEnvironmentSection(props: {
444489
});
445490
}}
446491
aria-pressed={variable.sensitive}
492+
disabled={typeof variable.value !== "string"}
447493
aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
448494
>
449495
<MorphIcon
@@ -584,6 +630,7 @@ interface ProviderInstanceCardProps {
584630
readonly isUpdating?: boolean | undefined;
585631
readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined;
586632
readonly environmentId?: EnvironmentId | undefined;
633+
readonly secretSources?: ReadonlyArray<SecretSource>;
587634
readonly projectOverride?: ProjectOverrideControl | undefined;
588635
readonly acpProjects?:
589636
| ReadonlyArray<{
@@ -595,6 +642,7 @@ interface ProviderInstanceCardProps {
595642
}
596643

597644
const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]> = [];
645+
const NO_SECRET_SOURCES: ReadonlyArray<SecretSource> = [];
598646
const NO_CUSTOM_MODELS: ReadonlyArray<CustomModelDefinition> = [];
599647

600648
/** Unknown drivers stay read-only to preserve fork config. projectOverride switches affect only the
@@ -627,6 +675,7 @@ export function ProviderInstanceCard({
627675
onAcceptUrlAuth,
628676
environmentId,
629677
acpProjects = EMPTY_ACP_PROJECTS,
678+
secretSources = NO_SECRET_SOURCES,
630679
projectOverride,
631680
}: ProviderInstanceCardProps) {
632681
const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance);
@@ -781,6 +830,12 @@ export function ProviderInstanceCard({
781830
const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => {
782831
updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value));
783832
};
833+
const updateEnvironmentFieldSecret = (
834+
field: ProviderEnvironmentField,
835+
secret: SecretReference | undefined,
836+
) => {
837+
updateEnvironment(nextProviderEnvironmentWithFieldSecret(instance.environment, field, secret));
838+
};
784839
const removeEnvironmentField = (field: ProviderEnvironmentField) => {
785840
updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name])));
786841
};
@@ -1182,7 +1237,9 @@ export function ProviderInstanceCard({
11821237
variable={readProviderEnvironmentVariable(instance.environment, field.name)}
11831238
idPrefix={`provider-instance-${instanceId}`}
11841239
onCommit={updateEnvironmentField}
1240+
onSecretChange={updateEnvironmentFieldSecret}
11851241
onRemove={removeEnvironmentField}
1242+
secretSources={secretSources}
11861243
/>
11871244
))}
11881245
</div>
@@ -1225,6 +1282,7 @@ export function ProviderInstanceCard({
12251282
<ProviderEnvironmentSection
12261283
environment={genericEnvironment}
12271284
onChange={updateGenericEnvironment}
1285+
secretSources={secretSources}
12281286
/>
12291287
</SettingsSection>
12301288
{environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? (

‎apps/web/src/components/settings/ProviderSettingsPanel.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
9898
import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
9999
import { ExpandableText } from "./ExpandableText";
100100
import { ProviderInstanceCard } from "./ProviderInstanceCard";
101+
import { useSecretSources } from "./SecretSourceInput";
101102
import { UsageProviderSettings } from "./UsageProviderSettings";
102103
import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
103104
import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection";
@@ -1247,6 +1248,7 @@ function ProviderInstanceRow({
12471248
readonly actions: ProviderInstanceRowActions;
12481249
}) {
12491250
const allProjects = useProjects();
1251+
const secretSources = useSecretSources(environmentId);
12501252
const acpProjects =
12511253
mode === "editor" && liveProvider?.driver === "acpRegistry"
12521254
? allProjects.filter((project) => project.environmentId === environmentId)
@@ -1285,6 +1287,7 @@ function ProviderInstanceRow({
12851287
return (
12861288
<ProviderInstanceCard
12871289
environmentId={environmentId}
1290+
secretSources={secretSources}
12881291
acpProjects={acpProjects}
12891292
projectOverride={projectOverride}
12901293
onAcceptUrlAuth={
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { SecretProviderId } from "@t3tools/contracts";
2+
import { describe, expect, it, vi } from "vite-plus/test";
3+
import { userEvent } from "vite-plus/test/browser";
4+
5+
import { render, type Screen } from "../../../test/render";
6+
import { SecretSourceInput } from "./SecretSourceInput";
7+
8+
const personal = SecretProviderId.make("personal");
9+
const work = SecretProviderId.make("work");
10+
const sources = [
11+
{ id: personal, label: "Personal" },
12+
{ id: work, label: "Work" },
13+
];
14+
15+
const reference = (screen: Screen) => screen.getByRole("textbox", { name: "API key reference" });
16+
17+
async function commitReference(screen: Screen, value: string) {
18+
await userEvent.clear(reference(screen));
19+
await userEvent.type(reference(screen), value);
20+
await userEvent.keyboard("{Enter}");
21+
}
22+
23+
describe("SecretSourceInput", () => {
24+
it("moves a reference to another provider only once one is committed for it", async () => {
25+
const onSecretChange = vi.fn();
26+
await using screen = await render(
27+
<SecretSourceInput
28+
aria-label="API key"
29+
value={{ provider: personal, reference: "op://Private/OpenAI/credential" }}
30+
sources={sources}
31+
onCommit={() => {}}
32+
onSecretChange={onSecretChange}
33+
/>,
34+
);
35+
36+
await userEvent.click(screen.getByRole("combobox", { name: "API key source" }));
37+
await userEvent.click(screen.getByRole("option", { name: "Work" }));
38+
expect(onSecretChange).not.toHaveBeenCalled();
39+
await expect.element(reference(screen)).toHaveValue("");
40+
41+
await commitReference(screen, "op://Shared/OpenAI/credential");
42+
expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({
43+
provider: work,
44+
reference: "op://Shared/OpenAI/credential",
45+
});
46+
});
47+
48+
it("removes the reference when its field is cleared", async () => {
49+
const onSecretChange = vi.fn();
50+
await using screen = await render(
51+
<SecretSourceInput
52+
aria-label="API key"
53+
value={{ provider: personal, reference: "op://Private/OpenAI/credential" }}
54+
sources={sources}
55+
onCommit={() => {}}
56+
onSecretChange={onSecretChange}
57+
/>,
58+
);
59+
60+
await userEvent.clear(reference(screen));
61+
await userEvent.keyboard("{Enter}");
62+
expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined);
63+
});
64+
65+
it("rejects a reference 1Password cannot read", async () => {
66+
const onSecretChange = vi.fn();
67+
await using screen = await render(
68+
<SecretSourceInput
69+
aria-label="API key"
70+
value={{ provider: personal, reference: "op://Private/OpenAI/credential" }}
71+
sources={sources}
72+
onCommit={() => {}}
73+
onSecretChange={onSecretChange}
74+
/>,
75+
);
76+
77+
await commitReference(screen, "Private/OpenAI/credential");
78+
expect(onSecretChange).not.toHaveBeenCalled();
79+
await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true");
80+
await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential");
81+
});
82+
});

0 commit comments

Comments
 (0)