Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/onepassword-multiple-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

1Password: multiple named accounts. The provider now holds any number of named accounts — a work account next to a personal one, or a service-account token next to desktop-app biometrics — each scoping its own set of vaults. The settings card lists every account with independent edit and disconnect, existing single-account configs upgrade in place, and `op://` refs keep their vault-first addressing: a vault name that exists in more than one account is an explicit ambiguity error, never a silent pick.
27 changes: 16 additions & 11 deletions packages/plugins/onepassword/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@ import { Schema } from "effect";
import { InternalError } from "@executor-js/sdk/shared";

import { OnePasswordError } from "../sdk/errors";
import {
OnePasswordConfig,
RedactedOnePasswordConfig,
Vault,
ConnectionStatus,
} from "../sdk/types";
import { OnePasswordAccountUpsert } from "../sdk/plugin";
import { RedactedOnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";

// ---------------------------------------------------------------------------
// Payloads
//
// v2: config is a single per-owner binding the extension derives from the
// executor's owner binding — there are no scope segments in the path. The
// configure payload carries the full config (including the service-account
// token); reads return the redacted projection so the token never leaves the
// plugin.
// configure payload carries one account upsert (including the
// service-account token); reads return the redacted projection so the token
// never leaves the plugin.
// ---------------------------------------------------------------------------

const ConfigurePayload = OnePasswordConfig;
const ConfigurePayload = OnePasswordAccountUpsert;

const ConfigureResponse = Schema.Struct({
accountId: Schema.String,
});

const RemoveConfigParams = Schema.Struct({
accountId: Schema.optional(Schema.String),
});

const ListVaultsParams = Schema.Struct({
authKind: Schema.Literals(["desktop-app", "service-account"]),
Expand Down Expand Up @@ -60,12 +64,13 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword")
.add(
HttpApiEndpoint.put("configure", "/onepassword/config", {
payload: ConfigurePayload,
success: Schema.Void,
success: ConfigureResponse,
error: [InternalError, OnePasswordError],
}),
)
.add(
HttpApiEndpoint.delete("removeConfig", "/onepassword/config", {
query: RemoveConfigParams,
success: Schema.Void,
error: [InternalError, OnePasswordError],
}),
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/onepassword/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ export const OnePasswordHandlers = HttpApiBuilder.group(
capture(
Effect.gen(function* () {
const ext = yield* OnePasswordExtensionService;
yield* ext.configure(payload);
return yield* ext.configure(payload);
}),
),
)
.handle("removeConfig", () =>
.handle("removeConfig", ({ query }) =>
capture(
Effect.gen(function* () {
const ext = yield* OnePasswordExtensionService;
yield* ext.removeConfig();
yield* ext.removeConfig(query.accountId);
}),
),
)
Expand Down
196 changes: 119 additions & 77 deletions packages/plugins/onepassword/src/react/OnePasswordSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
removeOnePasswordConfig,
onepasswordWriteKeys,
} from "./atoms";
import type { RedactedOnePasswordConfig, Vault } from "../sdk/types";
import type { RedactedOnePasswordAccount, RedactedOnePasswordConfig, Vault } from "../sdk/types";

// ---------------------------------------------------------------------------
// Vault picker — multi-select
Expand Down Expand Up @@ -170,13 +170,14 @@ function VaultPicker(props: {
}

// ---------------------------------------------------------------------------
// Config dialog
// Account dialog — add a new account, or edit one by id
// ---------------------------------------------------------------------------

function ConfigDialog(props: {
function AccountDialog(props: {
open: boolean;
onOpenChange: (v: boolean) => void;
initial?: {
id: string;
authKind: string;
accountName: string;
vaults: ReadonlyArray<Vault>;
Expand Down Expand Up @@ -221,6 +222,7 @@ function ConfigDialog(props: {

const exit = await doConfigure({
payload: {
...(props.initial ? { id: props.initial.id } : {}),
auth,
vaults: [firstVault, ...restVaults],
name: displayName.trim() || "1Password",
Expand Down Expand Up @@ -248,11 +250,11 @@ function ConfigDialog(props: {
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="font-display text-xl">
{isEdit ? "Edit 1Password" : "Connect 1Password"}
{isEdit ? "Edit 1Password account" : "Add 1Password account"}
</DialogTitle>
<DialogDescription className="text-[13px] leading-relaxed">
Link one or more vaults to resolve secrets via the 1Password desktop app or a service
account.
account. Add more accounts to keep work and personal credentials separate.
</DialogDescription>
</DialogHeader>

Expand Down Expand Up @@ -310,10 +312,10 @@ function ConfigDialog(props: {
{/* Display name */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Display name
Name
</Label>
<Input
placeholder="1Password"
placeholder="Work"
value={displayName}
onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)}
className="text-[13px] h-9"
Expand Down Expand Up @@ -347,18 +349,65 @@ function ConfigDialog(props: {
}

// ---------------------------------------------------------------------------
// Settings card
// Settings entries — one card per account, plus the add action
// ---------------------------------------------------------------------------

export default function OnePasswordSettings() {
const [configOpen, setConfigOpen] = useState(false);
const configResult = useAtomValue(onepasswordConfigAtom);
function AccountEntry(props: { account: RedactedOnePasswordAccount; onEdit: () => void }) {
const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" });
const [removing, setRemoving] = useState(false);

const handleRemove = async () => {
await doRemove({ reactivityKeys: onepasswordWriteKeys });
setRemoving(true);
await doRemove({
query: { accountId: props.account.id },
reactivityKeys: onepasswordWriteKeys,
});
setRemoving(false);
};

return (
<CardStackEntry>
<CardStackEntryContent>
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]">
<span className="text-muted-foreground/60">Name</span>
<span className="text-foreground/80 truncate">{props.account.name}</span>
<span className="text-muted-foreground/60">Auth</span>
<span className="font-mono text-foreground/80 truncate">
{props.account.auth.kind === "desktop-app"
? props.account.auth.accountName
: "service-account"}
</span>
<span className="text-muted-foreground/60">
{props.account.vaults.length === 1 ? "Vault" : "Vaults"}
</span>
<span className="text-foreground/80 truncate">
{props.account.vaults.map((vault) => vault.name).join(", ")}
</span>
</div>
</CardStackEntryContent>
<CardStackEntryActions>
<Button variant="ghost" size="sm" className="h-7 px-2.5 text-[12px]" onClick={props.onEdit}>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"
disabled={removing}
onClick={handleRemove}
>
Disconnect
</Button>
</CardStackEntryActions>
</CardStackEntry>
);
}

export default function OnePasswordSettings() {
// null = closed; "new" = add flow; otherwise the account being edited.
const [dialogTarget, setDialogTarget] = useState<RedactedOnePasswordAccount | "new" | null>(null);
const configResult = useAtomValue(onepasswordConfigAtom);

const config: RedactedOnePasswordConfig | null = AsyncResult.match(
configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>,
{
Expand All @@ -384,88 +433,81 @@ export default function OnePasswordSettings() {
},
);

const accounts = config?.accounts ?? [];

return (
<>
<CardStackEntry>
<CardStackEntryContent>
{isLoading ? (
<CardStackEntryDescription>Loading…</CardStackEntryDescription>
) : isError ? (
<CardStackEntryDescription className="text-destructive">
Failed to load configuration
</CardStackEntryDescription>
) : config ? (
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]">
<span className="text-muted-foreground/60">Auth</span>
<span className="font-mono text-foreground/80 truncate">
{config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
</span>
<span className="text-muted-foreground/60">
{config.vaults.length === 1 ? "Vault" : "Vaults"}
</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-foreground/80 truncate">
{config.vaults.map((vault) => vault.name).join(", ")}
</span>
</div>
</div>
) : (
<CardStackEntryDescription>
Resolve secrets from your 1Password vaults.
</CardStackEntryDescription>
)}
</CardStackEntryContent>
<CardStackEntryActions>
{config ? (
<>
{isLoading || isError || accounts.length === 0 ? (
<CardStackEntry>
<CardStackEntryContent>
{isLoading ? (
<CardStackEntryDescription>Loading…</CardStackEntryDescription>
) : isError ? (
<CardStackEntryDescription className="text-destructive">
Failed to load configuration
</CardStackEntryDescription>
) : (
<CardStackEntryDescription>
Resolve secrets from your 1Password vaults.
</CardStackEntryDescription>
)}
</CardStackEntryContent>
<CardStackEntryActions>
{!isLoading && !isError && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-[12px]"
onClick={() => setConfigOpen(true)}
>
Edit
</Button>
<Button
variant="ghost"
variant="link"
size="sm"
className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"
onClick={handleRemove}
className="h-7 px-0 text-[12px] shrink-0"
onClick={() => setDialogTarget("new")}
>
Disconnect
Add 1Password
</Button>
</>
) : (
!isLoading &&
!isError && (
)}
</CardStackEntryActions>
</CardStackEntry>
) : (
<>
{accounts.map((account) => (
<AccountEntry
key={account.id}
account={account}
onEdit={() => setDialogTarget(account)}
/>
))}
<CardStackEntry>
<CardStackEntryContent>
<Button
variant="link"
size="sm"
className="h-7 px-0 text-[12px] shrink-0"
onClick={() => setConfigOpen(true)}
onClick={() => setDialogTarget("new")}
>
Add 1Password
+ Add 1Password account
</Button>
)
)}
</CardStackEntryActions>
</CardStackEntry>
</CardStackEntryContent>
</CardStackEntry>
</>
)}

{configOpen && (
<ConfigDialog
open={configOpen}
onOpenChange={setConfigOpen}
{dialogTarget !== null && (
<AccountDialog
open={dialogTarget !== null}
onOpenChange={(v) => {
if (!v) setDialogTarget(null);
}}
initial={
config
? {
authKind: config.auth.kind,
dialogTarget === "new"
? undefined
: {
id: dialogTarget.id,
authKind: dialogTarget.auth.kind,
// Service-account tokens are never surfaced (redacted); the
// user re-enters the token when editing that auth method.
accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "",
vaults: config.vaults,
name: config.name,
accountName:
dialogTarget.auth.kind === "desktop-app" ? dialogTarget.auth.accountName : "",
vaults: dialogTarget.vaults,
name: dialogTarget.name,
}
: undefined
}
/>
)}
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/onepassword/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ export {
makeOnePasswordStore,
resolveConfiguredRef,
ambiguityMessage,
vaultAmbiguityMessage,
OnePasswordAccountUpsert,
type RefResolution,
type OnePasswordExtension,
type OnePasswordPluginOptions,
type OnePasswordStore,
} from "./plugin";
export {
OnePasswordAccount,
OnePasswordConfig,
SingleAccountOnePasswordConfig,
LegacyOnePasswordConfig,
StoredOnePasswordConfig,
DEFAULT_ACCOUNT_ID,
normalizeStoredConfig,
RedactedOnePasswordAccount,
RedactedOnePasswordConfig,
RedactedOnePasswordAuth,
redactAccount,
redactConfig,
Vault,
AccountStatus,
ConnectionStatus,
OnePasswordAuth,
DesktopAppAuth,
Expand Down
Loading
Loading