Skip to content

Commit 369fa0a

Browse files
authored
Support multiple 1Password vaults (#1829)
* Support multiple 1Password vaults per configuration * Make multi-vault 1Password refs explicit and item picker searchable * Serve cached provider listings while revalidating in pickers * Ship pending changesets as a patch release
1 parent 02b52cd commit 369fa0a

13 files changed

Lines changed: 643 additions & 169 deletions

File tree

.changeset/defer-irreversible-cleanup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
"executor": minor
2+
"executor": patch
33
---
44

55
**Irreversible cleanup now waits for the transaction to commit, and plugins can do the same**
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing**
6+
7+
The provider previously bound exactly one vault. The configuration now holds a set of vaults selected with checkboxes, and every reference is explicit about which vault it means: the item picker is a searchable list that shows each item's vault and stores a vault-qualified `op://` reference, so identically-titled items in different vaults can never collide. A bare item name is accepted only when it matches exactly one item across the selected vaults — a name that exists in more than one place fails with an error naming the matching vaults instead of silently picking one.
8+
9+
Reopening the vault or item pickers no longer flashes a loading state: listings are retained and re-validated in the background, so the last-known list renders instantly.
10+
11+
Configurations saved before this change keep working: the stored single-vault shape is read as a one-vault list and upgrades to the new shape the next time it is saved. The `status` tool reports `vaultNames` for all configured vaults and flags any configured vault the account can no longer see. Provider entries also gained an optional `group` label, which pickers use to show where an item lives.

packages/core/api/src/handlers/providers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers",
2121
Effect.gen(function* () {
2222
const executor = yield* ExecutorService;
2323
const entries = yield* executor.providers.items(path.key);
24-
return entries.map((entry) => ({ id: entry.id, name: entry.name }));
24+
return entries.map((entry) => ({
25+
id: entry.id,
26+
name: entry.name,
27+
...(entry.group !== undefined ? { group: entry.group } : {}),
28+
}));
2529
}),
2630
),
2731
),

packages/core/api/src/providers/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey };
2626
const ProviderEntryResponse = Schema.Struct({
2727
id: ProviderItemId,
2828
name: Schema.String,
29+
group: Schema.optional(Schema.String),
2930
});
3031

3132
// ---------------------------------------------------------------------------

packages/core/sdk/src/provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export interface ProviderEntry {
1616
* a connection can reference it without core knowing its internal shape. */
1717
readonly id: ProviderItemId;
1818
readonly name: string;
19+
/** Optional provenance label for pickers when a provider spans several
20+
* containers (a 1Password vault name). Purely presentational. */
21+
readonly group?: string;
1922
}
2023

2124
export interface CredentialProvider {

packages/plugins/onepassword/src/react/OnePasswordSettings.tsx

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState } from "react";
2-
import { useAtomSet, useAtomValue } from "@effect/atom-react";
1+
import { useEffect, useRef, useState } from "react";
2+
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
33
import * as Exit from "effect/Exit";
44
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55
import { Button } from "@executor-js/react/components/button";
6+
import { Checkbox } from "@executor-js/react/components/checkbox";
67
import { Input } from "@executor-js/react/components/input";
78
import { Label } from "@executor-js/react/components/label";
89
import {
@@ -35,10 +36,10 @@ import {
3536
removeOnePasswordConfig,
3637
onepasswordWriteKeys,
3738
} from "./atoms";
38-
import type { RedactedOnePasswordConfig } from "../sdk/types";
39+
import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";
3940

4041
// ---------------------------------------------------------------------------
41-
// Vault picker
42+
// Vault picker — multi-select
4243
// ---------------------------------------------------------------------------
4344

4445
const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults";
@@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => {
5253
function VaultPicker(props: {
5354
authKind: "desktop-app" | "service-account";
5455
accountName: string;
55-
vaultId: string;
56-
onVaultSelect: (id: string, name: string) => void;
56+
selected: ReadonlyArray<Vault>;
57+
onSelectedChange: (vaults: ReadonlyArray<Vault>) => void;
5758
}) {
5859
const account = props.accountName.trim();
59-
const vaultsResult = useAtomValue(onepasswordVaultsAtom(props.authKind, account));
60+
const vaultsAtom = onepasswordVaultsAtom(props.authKind, account);
61+
const vaultsResult = useAtomValue(vaultsAtom);
62+
const refreshVaults = useAtomRefresh(vaultsAtom);
63+
64+
// Stale-while-revalidate: with a retained value the vault list renders
65+
// instantly and one background refresh per atom key picks up changes
66+
// (refreshing keeps the previous value, so nothing flashes). A cold key is
67+
// already fetching — refreshing it would only restart the request. The ref
68+
// carries the latest cached-ness into the effect without re-running it.
69+
const isCachedRef = useRef(false);
70+
isCachedRef.current = AsyncResult.isSuccess(vaultsResult);
71+
useEffect(() => {
72+
if (isCachedRef.current) refreshVaults();
73+
}, [refreshVaults]);
6074

6175
const { vaults, isLoading, error } = AsyncResult.matchWithError(
6276
vaultsResult as AsyncResult.AsyncResult<
@@ -81,12 +95,9 @@ function VaultPicker(props: {
8195
}),
8296
onSuccess: ({ value }) => {
8397
const v = value.vaults;
84-
const defaultVault = v[0];
85-
if (
86-
defaultVault &&
87-
(!props.vaultId || (v.length === 1 && props.vaultId !== defaultVault.id))
88-
) {
89-
queueMicrotask(() => props.onVaultSelect(defaultVault.id, defaultVault.name));
98+
const onlyVault = v.length === 1 ? v[0] : undefined;
99+
if (onlyVault && props.selected.length === 0) {
100+
queueMicrotask(() => props.onSelectedChange([onlyVault]));
90101
}
91102
return { vaults: [...v], isLoading: false, error: null };
92103
},
@@ -101,34 +112,51 @@ function VaultPicker(props: {
101112
);
102113
}
103114

104-
const singleVault = vaults.length === 1 ? vaults[0] : null;
115+
// Selected vaults missing from the loaded list (renamed, revoked, or the
116+
// list failed to load while editing) stay visible so they can be unchecked.
117+
const loadedIds = new Set(vaults.map((v) => v.id));
118+
const stale = props.selected.filter((v) => !loadedIds.has(v.id));
119+
const rows = [...vaults, ...stale];
120+
121+
const toggle = (vault: Vault, checked: boolean) => {
122+
if (checked) {
123+
if (!props.selected.some((v) => v.id === vault.id)) {
124+
props.onSelectedChange([...props.selected, vault]);
125+
}
126+
return;
127+
}
128+
props.onSelectedChange(props.selected.filter((v) => v.id !== vault.id));
129+
};
105130

106131
return (
107132
<div className="grid gap-2">
108-
{singleVault ? (
109-
<div className="flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px] text-foreground">
110-
<span className="truncate">{singleVault.name}</span>
111-
</div>
133+
{isLoading ? (
134+
<p className="text-[11px] text-muted-foreground/50 py-1">Loading vaults…</p>
135+
) : rows.length === 0 ? (
136+
<p className="text-[11px] text-muted-foreground/50 py-1">No vaults found.</p>
112137
) : (
113-
<Select
114-
disabled={isLoading || vaults.length === 0}
115-
value={props.vaultId}
116-
onValueChange={(id) => {
117-
const v = vaults.find((vault) => vault.id === id);
118-
if (v) props.onVaultSelect(v.id, v.name);
119-
}}
120-
>
121-
<SelectTrigger className="h-9 text-[13px]">
122-
<SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} />
123-
</SelectTrigger>
124-
<SelectContent>
125-
{vaults.map((v) => (
126-
<SelectItem key={v.id} value={v.id}>
127-
{v.name}
128-
</SelectItem>
129-
))}
130-
</SelectContent>
131-
</Select>
138+
<div className="grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1">
139+
{rows.map((vault) => {
140+
const checked = props.selected.some((v) => v.id === vault.id);
141+
return (
142+
<Label
143+
key={vault.id}
144+
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40"
145+
>
146+
<Checkbox
147+
checked={checked}
148+
onCheckedChange={(value) => toggle(vault, value === true)}
149+
/>
150+
<span className="truncate text-[13px] text-foreground">{vault.name}</span>
151+
{!loadedIds.has(vault.id) && (
152+
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground/50">
153+
not found
154+
</span>
155+
)}
156+
</Label>
157+
);
158+
})}
159+
</div>
132160
)}
133161
{error && (
134162
<div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">
@@ -151,7 +179,7 @@ function ConfigDialog(props: {
151179
initial?: {
152180
authKind: string;
153181
accountName: string;
154-
vaultId: string;
182+
vaults: ReadonlyArray<Vault>;
155183
name: string;
156184
};
157185
}) {
@@ -160,8 +188,10 @@ function ConfigDialog(props: {
160188
(props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",
161189
);
162190
const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");
163-
const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? "");
164-
const [vaultName, setVaultName] = useState(props.initial?.name ?? "");
191+
const [selectedVaults, setSelectedVaults] = useState<ReadonlyArray<Vault>>(
192+
props.initial?.vaults ?? [],
193+
);
194+
const [displayName, setDisplayName] = useState(props.initial?.name ?? "");
165195
const [saving, setSaving] = useState(false);
166196
const [error, setError] = useState<string | null>(null);
167197

@@ -171,15 +201,16 @@ function ConfigDialog(props: {
171201
if (!isEdit) {
172202
setAuthKind("desktop-app");
173203
setAccountName("my.1password.com");
174-
setVaultId("");
175-
setVaultName("");
204+
setSelectedVaults([]);
205+
setDisplayName("");
176206
}
177207
setError(null);
178208
setSaving(false);
179209
};
180210

181211
const handleSave = async () => {
182-
if (!accountName.trim() || !vaultId.trim()) return;
212+
const [firstVault, ...restVaults] = selectedVaults;
213+
if (!accountName.trim() || firstVault === undefined) return;
183214
setSaving(true);
184215
setError(null);
185216

@@ -191,8 +222,8 @@ function ConfigDialog(props: {
191222
const exit = await doConfigure({
192223
payload: {
193224
auth,
194-
vaultId: vaultId.trim(),
195-
name: vaultName.trim() || "1Password",
225+
vaults: [firstVault, ...restVaults],
226+
name: displayName.trim() || "1Password",
196227
},
197228
reactivityKeys: onepasswordWriteKeys,
198229
});
@@ -220,7 +251,8 @@ function ConfigDialog(props: {
220251
{isEdit ? "Edit 1Password" : "Connect 1Password"}
221252
</DialogTitle>
222253
<DialogDescription className="text-[13px] leading-relaxed">
223-
Link a vault to resolve secrets via the 1Password desktop app or a service account.
254+
Link one or more vaults to resolve secrets via the 1Password desktop app or a service
255+
account.
224256
</DialogDescription>
225257
</DialogHeader>
226258

@@ -262,19 +294,16 @@ function ConfigDialog(props: {
262294
</p>
263295
</div>
264296

265-
{/* Vault */}
297+
{/* Vaults */}
266298
<div className="grid gap-1.5">
267299
<Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
268-
Vault
300+
Vaults
269301
</Label>
270302
<VaultPicker
271303
authKind={authKind}
272304
accountName={accountName}
273-
vaultId={vaultId}
274-
onVaultSelect={(id, name) => {
275-
setVaultId(id);
276-
setVaultName(name);
277-
}}
305+
selected={selectedVaults}
306+
onSelectedChange={setSelectedVaults}
278307
/>
279308
</div>
280309

@@ -285,8 +314,8 @@ function ConfigDialog(props: {
285314
</Label>
286315
<Input
287316
placeholder="1Password"
288-
value={vaultName}
289-
onChange={(e) => setVaultName((e.target as HTMLInputElement).value)}
317+
value={displayName}
318+
onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
290319
className="text-[13px] h-9"
291320
/>
292321
</div>
@@ -307,7 +336,7 @@ function ConfigDialog(props: {
307336
<Button
308337
size="sm"
309338
onClick={handleSave}
310-
disabled={!accountName.trim() || !vaultId.trim() || saving}
339+
disabled={!accountName.trim() || selectedVaults.length === 0 || saving}
311340
>
312341
{saving ? "Saving…" : isEdit ? "Update" : "Connect"}
313342
</Button>
@@ -371,14 +400,18 @@ export default function OnePasswordSettings() {
371400
<span className="font-mono text-foreground/80 truncate">
372401
{config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
373402
</span>
374-
<span className="text-muted-foreground/60">Vault</span>
403+
<span className="text-muted-foreground/60">
404+
{config.vaults.length === 1 ? "Vault" : "Vaults"}
405+
</span>
375406
<div className="flex items-center gap-2 min-w-0">
376-
<span className="text-foreground/80 truncate">{config.name}</span>
407+
<span className="text-foreground/80 truncate">
408+
{config.vaults.map((vault) => vault.name).join(", ")}
409+
</span>
377410
</div>
378411
</div>
379412
) : (
380413
<CardStackEntryDescription>
381-
Resolve secrets from your 1Password vault.
414+
Resolve secrets from your 1Password vaults.
382415
</CardStackEntryDescription>
383416
)}
384417
</CardStackEntryContent>
@@ -429,7 +462,7 @@ export default function OnePasswordSettings() {
429462
// Service-account tokens are never surfaced (redacted); the
430463
// user re-enters the token when editing that auth method.
431464
accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
432-
vaultId: config.vaultId,
465+
vaults: config.vaults,
433466
name: config.name,
434467
}
435468
: undefined

packages/plugins/onepassword/src/react/atoms.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = (
3333
) =>
3434
OnePasswordClient.query("onepassword", "listVaults", {
3535
query: { authKind, account },
36-
timeToLive: "30 seconds",
36+
// Long retention on purpose: vault listing goes through the op CLI/SDK and
37+
// is slow, so a reopened dialog renders the last-known vaults instantly
38+
// and revalidates in the background instead of flashing a loading state.
39+
timeToLive: "10 minutes",
3740
reactivityKeys: [ReactivityKey.providers],
3841
});
3942

packages/plugins/onepassword/src/sdk/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
export {
22
onepasswordPlugin,
33
makeOnePasswordStore,
4+
resolveConfiguredRef,
5+
ambiguityMessage,
6+
type RefResolution,
47
type OnePasswordExtension,
58
type OnePasswordPluginOptions,
69
type OnePasswordStore,
710
} from "./plugin";
811
export {
912
OnePasswordConfig,
13+
LegacyOnePasswordConfig,
14+
StoredOnePasswordConfig,
15+
normalizeStoredConfig,
1016
RedactedOnePasswordConfig,
1117
RedactedOnePasswordAuth,
1218
redactConfig,

0 commit comments

Comments
 (0)