418 lines · 13.7 KB · TypeScript
RawDownload
import { useState } from "react";import { useAtomSet, useAtomValue } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";import { useScope } from "@executor-js/react/api/scope-context";import { Button } from "@executor-js/react/components/button";import { Input } from "@executor-js/react/components/input";import { Label } from "@executor-js/react/components/label";import {  Select,  SelectContent,  SelectItem,  SelectTrigger,  SelectValue,} from "@executor-js/react/components/select";import {  Dialog,  DialogContent,  DialogHeader,  DialogTitle,  DialogDescription,  DialogFooter,  DialogClose,} from "@executor-js/react/components/dialog";import {  CardStackEntry,  CardStackEntryActions,  CardStackEntryContent,  CardStackEntryDescription,} from "@executor-js/react/components/card-stack"; import {  onepasswordConfigAtom,  onepasswordVaultsAtom,  configureOnePassword,  removeOnePasswordConfig,} from "./atoms";import type { OnePasswordConfig } from "../sdk/types"; // ---------------------------------------------------------------------------// Vault picker// --------------------------------------------------------------------------- function VaultPicker(props: {  authKind: "desktop-app" | "service-account";  accountName: string;  vaultId: string;  onVaultSelect: (id: string, name: string) => void;}) {  const account = props.accountName.trim();  const scopeId = useScope();  const vaultsResult = useAtomValue(onepasswordVaultsAtom(props.authKind, account, scopeId));   const { vaults, isLoading, error } = AsyncResult.matchWithError(    vaultsResult as AsyncResult.AsyncResult<      { vaults: ReadonlyArray<{ id: string; name: string }> },      Error    >,    {      onInitial: () => ({        vaults: [] as { id: string; name: string }[],        isLoading: true,        error: null,      }),      onError: () => ({        vaults: [] as { id: string; name: string }[],        isLoading: false,        error: "Failed to list vaults",      }),      onDefect: () => ({        vaults: [] as { id: string; name: string }[],        isLoading: false,        error: "Failed to list vaults",      }),      onSuccess: ({ value }) => {        const v = value.vaults;        if (v.length > 0 && !props.vaultId) {          queueMicrotask(() => props.onVaultSelect(v[0].id, v[0].name));        }        return { vaults: [...v], isLoading: false, error: null };      },    },  );   if (!account) {    return (      <p className="text-[11px] text-muted-foreground/50 py-1">        Enter account details to load vaults.      </p>    );  }   return (    <div className="grid gap-2">      <Select        disabled={isLoading || vaults.length === 0}        value={props.vaultId}        onValueChange={(id) => {          const v = vaults.find((vault) => vault.id === id);          if (v) props.onVaultSelect(v.id, v.name);        }}      >        <SelectTrigger className="h-9 text-[13px]">          <SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} />        </SelectTrigger>        <SelectContent>          {vaults.map((v) => (            <SelectItem key={v.id} value={v.id}>              {v.name}            </SelectItem>          ))}        </SelectContent>      </Select>      {error && (        <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">          <p className="text-[11px] text-destructive leading-relaxed whitespace-pre-line">            {error}          </p>        </div>      )}    </div>  );} // ---------------------------------------------------------------------------// Config dialog// --------------------------------------------------------------------------- function ConfigDialog(props: {  open: boolean;  onOpenChange: (v: boolean) => void;  initial?: { authKind: string; accountName: string; vaultId: string; name: string };}) {  const isEdit = !!props.initial;  const [authKind, setAuthKind] = useState<"desktop-app" | "service-account">(    (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app",  );  const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com");  const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? "");  const [vaultName, setVaultName] = useState(props.initial?.name ?? "");  const [saving, setSaving] = useState(false);  const [error, setError] = useState<string | null>(null);   const scopeId = useScope();  const doConfigure = useAtomSet(configureOnePassword, { mode: "promiseExit" });   const reset = () => {    if (!isEdit) {      setAuthKind("desktop-app");      setAccountName("my.1password.com");      setVaultId("");      setVaultName("");    }    setError(null);    setSaving(false);  };   const handleSave = async () => {    if (!accountName.trim() || !vaultId.trim()) return;    setSaving(true);    setError(null);     const auth =      authKind === "desktop-app"        ? { kind: "desktop-app" as const, accountName: accountName.trim() }        : { kind: "service-account" as const, tokenSecretId: accountName.trim() };     const exit = await doConfigure({      params: { scopeId },      payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" },      reactivityKeys: [ReactivityKey.secrets],    });    if (Exit.isFailure(exit)) {      setError("Failed to save configuration");      setSaving(false);      return;    }     props.onOpenChange(false);    reset();  };   return (    <Dialog      open={props.open}      onOpenChange={(v) => {        if (!v) reset();        props.onOpenChange(v);      }}    >      <DialogContent className="sm:max-w-[420px]">        <DialogHeader>          <DialogTitle className="font-display text-xl">            {isEdit ? "Edit 1Password" : "Connect 1Password"}          </DialogTitle>          <DialogDescription className="text-[13px] leading-relaxed">            Link a vault to resolve secrets via the 1Password desktop app or a service account.          </DialogDescription>        </DialogHeader>         <div className="grid gap-5 py-3">          {/* Auth method */}          <div className="grid gap-1.5">            <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">              Auth method            </Label>            <Select              value={authKind}              onValueChange={(v) => setAuthKind(v as "desktop-app" | "service-account")}            >              <SelectTrigger className="h-9 text-[13px]">                <SelectValue />              </SelectTrigger>              <SelectContent>                <SelectItem value="desktop-app">Desktop App (biometric)</SelectItem>                <SelectItem value="service-account">Service Account</SelectItem>              </SelectContent>            </Select>          </div>           {/* Account / token */}          <div className="grid gap-1.5">            <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">              {authKind === "desktop-app" ? "Account domain" : "Token secret ID"}            </Label>            <Input              placeholder={authKind === "desktop-app" ? "my.1password.com" : "op-service-token"}              value={accountName}              onChange={(e) => setAccountName((e.target as HTMLInputElement).value)}              className="font-mono text-[13px] h-9"            />            <p className="text-[11px] text-muted-foreground/60 leading-relaxed">              {authKind === "desktop-app"                ? "Requires the 1Password desktop app with biometric unlock."                : "Reference an executor secret that holds the service account token."}            </p>          </div>           {/* Vault */}          <div className="grid gap-1.5">            <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">              Vault            </Label>            <VaultPicker              authKind={authKind}              accountName={accountName}              vaultId={vaultId}              onVaultSelect={(id, name) => {                setVaultId(id);                setVaultName(name);              }}            />            {vaultId && <p className="font-mono text-[10px] text-muted-foreground/50">{vaultId}</p>}          </div>           {/* Display name */}          <div className="grid gap-1.5">            <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">              Display name            </Label>            <Input              placeholder="1Password"              value={vaultName}              onChange={(e) => setVaultName((e.target as HTMLInputElement).value)}              className="text-[13px] h-9"            />          </div>           {error && (            <div className="rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2">              <p className="text-[12px] text-destructive whitespace-pre-line">{error}</p>            </div>          )}        </div>         <DialogFooter>          <DialogClose asChild>            <Button variant="ghost" size="sm">              Cancel            </Button>          </DialogClose>          <Button            size="sm"            onClick={handleSave}            disabled={!accountName.trim() || !vaultId.trim() || saving}          >            {saving ? "Saving…" : isEdit ? "Update" : "Connect"}          </Button>        </DialogFooter>      </DialogContent>    </Dialog>  );} // ---------------------------------------------------------------------------// Settings card// --------------------------------------------------------------------------- export default function OnePasswordSettings() {  const [configOpen, setConfigOpen] = useState(false);  const scopeId = useScope();  const configResult = useAtomValue(onepasswordConfigAtom(scopeId));  const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" });   const handleRemove = async () => {    await doRemove({ params: { scopeId }, reactivityKeys: [ReactivityKey.secrets] });  };   const config: OnePasswordConfig | null = AsyncResult.match(    configResult as AsyncResult.AsyncResult<OnePasswordConfig | null, unknown>,    { onInitial: () => null, onFailure: () => null, onSuccess: ({ value }) => value },  );  const isLoading = AsyncResult.match(    configResult as AsyncResult.AsyncResult<OnePasswordConfig | null, unknown>,    {      onInitial: () => true,      onFailure: () => false,      onSuccess: () => false,    },  );  const isError = AsyncResult.match(    configResult as AsyncResult.AsyncResult<OnePasswordConfig | null, unknown>,    {      onInitial: () => false,      onFailure: () => true,      onSuccess: () => false,    },  );   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">Vault</span>              <div className="flex items-center gap-2 min-w-0">                <span className="text-foreground/80 truncate">{config.name}</span>                <span className="font-mono text-[10px] text-muted-foreground/40 truncate">                  {config.vaultId}                </span>              </div>            </div>          ) : (            <CardStackEntryDescription>              Resolve secrets from your 1Password vault.            </CardStackEntryDescription>          )}        </CardStackEntryContent>        <CardStackEntryActions>          {config ? (            <>              <Button                variant="ghost"                size="sm"                className="h-7 px-2.5 text-[12px]"                onClick={() => setConfigOpen(true)}              >                Edit              </Button>              <Button                variant="ghost"                size="sm"                className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive"                onClick={handleRemove}              >                Disconnect              </Button>            </>          ) : (            !isLoading &&            !isError && (              <Button                variant="link"                size="sm"                className="h-7 px-0 text-[12px] shrink-0"                onClick={() => setConfigOpen(true)}              >                Add 1Password              </Button>            )          )}        </CardStackEntryActions>      </CardStackEntry>       {configOpen && (        <ConfigDialog          open={configOpen}          onOpenChange={setConfigOpen}          initial={            config              ? {                  authKind: config.auth.kind,                  accountName:                    config.auth.kind === "desktop-app"                      ? config.auth.accountName                      : config.auth.tokenSecretId,                  vaultId: config.vaultId,                  name: config.name,                }              : undefined          }        />      )}    </>  );}