354 lines · 10.9 KB · TypeScript
RawDownload
import {  createContext,  use,  useId,  useMemo,  useState,  type CSSProperties,  type ReactNode,} from "react";import { useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit"; import { createConnection } from "../api/atoms";import { connectionWriteKeys } from "../api/reactivity-keys";import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  type Owner,} from "@executor-js/sdk/shared";import { Button, type buttonVariants } from "../components/button";import { Field, FieldError, FieldLabel } from "../components/field";import { Input } from "../components/input";import type { VariantProps } from "class-variance-authority"; import { secretValueInputType } from "./secret-input";import { getUniqueSecretId, isSecretIdTaken } from "./secret-id"; // ---------------------------------------------------------------------------// Connection-create form (v2) — successor to v1's secret form.//// v1 minted a standalone secret bound to a scope. v2 mints a Connection: a// value pasted by the user, saved under an owner (Personal | Workspace) for one// integration's auth template. The compound API (`state`/`actions`/`meta`) is// kept so existing call sites compose `SecretForm.*` parts; the payload is now a// `createConnection` mutation.// --------------------------------------------------------------------------- type SubmitStatus =  | { readonly kind: "idle" }  | { readonly kind: "submitting" }  | { readonly kind: "error"; readonly message: string }; interface SecretFormState {  readonly name: string;  readonly value: string;  readonly idOverride: string | null;  readonly provider: string;  readonly revealed: boolean;  readonly status: SubmitStatus;} interface SecretFormActions {  readonly setName: (v: string) => void;  readonly setValue: (v: string) => void;  readonly setIdOverride: (v: string) => void;  readonly setProvider: (v: string) => void;  readonly toggleReveal: () => void;  readonly submit: () => Promise<void>;} interface SecretFormMeta {  readonly id: string;  readonly duplicateError: string | null;  readonly canSubmit: boolean;} interface SecretFormContextValue {  readonly state: SecretFormState;  readonly actions: SecretFormActions;  readonly meta: SecretFormMeta;} const SecretFormContext = createContext<SecretFormContextValue | null>(null); function useSecretForm(): SecretFormContextValue {  const ctx = use(SecretFormContext);  if (!ctx) {    // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React context invariant surfaces programmer misuse during render    throw new Error("SecretForm parts must be rendered inside <SecretForm.Provider>");  }  return ctx;} // ---------------------------------------------------------------------------// Provider// --------------------------------------------------------------------------- interface SecretFormProviderProps {  readonly existingSecretIds: readonly string[];  readonly suggestedName?: string;  readonly fallbackId?: string;  /** Force the connection name to a pre-allocated value. */  readonly initialIdOverride?: string;  readonly initialProvider?: string;  /** Owner the new connection is saved under (Personal | Workspace). */  readonly owner: Owner;  /** The integration the connection authenticates. */  readonly integration: IntegrationSlug;  /** Which of the integration's auth methods the value applies through. */  readonly template: AuthTemplateSlug;  readonly onCreated: (connectionName: string) => void;  readonly children: ReactNode;} function SecretFormProvider(props: SecretFormProviderProps) {  const {    existingSecretIds,    suggestedName = "",    fallbackId = "credential",    initialIdOverride,    initialProvider = "auto",    owner,    integration,    template,    onCreated,    children,  } = props;   const doCreate = useAtomSet(createConnection, { mode: "promiseExit" });   const [state, setState] = useState<SecretFormState>(() => ({    name: suggestedName,    value: "",    idOverride: initialIdOverride ?? null,    provider: initialProvider,    revealed: false,    status: { kind: "idle" },  }));   const baseName = state.name || suggestedName;  const autoId = useMemo(    () => getUniqueSecretId(baseName, existingSecretIds, fallbackId),    [baseName, existingSecretIds, fallbackId],  );  const id = state.idOverride ?? autoId;  const duplicateError =    state.idOverride !== null && isSecretIdTaken(state.idOverride, existingSecretIds)      ? "Name already exists"      : null;   const displayName = state.name.trim() || suggestedName.trim();  const canSubmit =    id.trim() !== "" &&    state.value.trim() !== "" &&    duplicateError === null &&    state.status.kind !== "submitting";   const submit = async () => {    if (!canSubmit) return;    setState((s) => ({ ...s, status: { kind: "submitting" } }));    const exit = await doCreate({      payload: {        owner,        name: ConnectionName.make(id.trim()),        integration,        template,        identityLabel: displayName || id.trim(),        value: state.value.trim(),      },      reactivityKeys: connectionWriteKeys,    });    if (Exit.isFailure(exit)) {      setState((s) => ({        ...s,        status: {          kind: "error",          message: "Failed to save credential",        },      }));      return;    }    onCreated(id.trim());  };   const value: SecretFormContextValue = {    state,    actions: {      setName: (v) => setState((s) => ({ ...s, name: v })),      setValue: (v) => setState((s) => ({ ...s, value: v })),      setIdOverride: (v) => setState((s) => ({ ...s, idOverride: v })),      setProvider: (v) => setState((s) => ({ ...s, provider: v })),      toggleReveal: () => setState((s) => ({ ...s, revealed: !s.revealed })),      submit,    },    meta: { id, duplicateError, canSubmit },  };   return <SecretFormContext value={value}>{children}</SecretFormContext>;} // ---------------------------------------------------------------------------// Parts// --------------------------------------------------------------------------- function NameField(props: { label?: string; placeholder?: string }) {  const { state, actions } = useSecretForm();  const inputId = useId();  return (    <Field>      <FieldLabel htmlFor={inputId}>{props.label ?? "Name"}</FieldLabel>      <Input        id={inputId}        value={state.name}        onChange={(e) => actions.setName((e.target as HTMLInputElement).value)}        placeholder={props.placeholder ?? "GitHub PAT"}      />    </Field>  );} function IdField(props: { placeholder?: string }) {  const { actions, meta } = useSecretForm();  const inputId = useId();  return (    <Field>      <FieldLabel htmlFor={inputId}>ID</FieldLabel>      <Input        id={inputId}        value={meta.id}        onChange={(e) => actions.setIdOverride((e.target as HTMLInputElement).value)}        placeholder={props.placeholder ?? "github-token"}        className="font-mono"      />      {meta.duplicateError && <FieldError>{meta.duplicateError}</FieldError>}    </Field>  );} function ValueField(props: { revealable?: boolean; placeholder?: string; autoFocus?: boolean }) {  const { state, actions } = useSecretForm();  const inputId = useId();  const revealable = props.revealable ?? false;  const revealed = revealable && state.revealed;  const errored = state.status.kind === "error";   return (    <Field>      <FieldLabel htmlFor={inputId}>Value</FieldLabel>      <div className="relative" data-ph-block>        <Input          id={inputId}          type={secretValueInputType({ revealable, revealed })}          value={state.value}          onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}          placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}          autoFocus={props.autoFocus}          autoComplete="new-password"          className={revealable ? "pr-9 font-mono" : "font-mono"}          style={            revealable && !revealed ? ({ WebkitTextSecurity: "disc" } as CSSProperties) : undefined          }          data-ph-block        />        {revealable && (          <Button            type="button"            variant="ghost"            size="icon-xs"            className="absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"            onClick={actions.toggleReveal}            aria-label={state.revealed ? "Hide value" : "Reveal value"}          >            <SecretVisibilityIcon revealed={state.revealed} />          </Button>        )}      </div>      {errored && (        <FieldError>{state.status.kind === "error" ? state.status.message : ""}</FieldError>      )}    </Field>  );} function ErrorBanner() {  const { state } = useSecretForm();  if (state.status.kind !== "error") return null;  return (    <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">      <p className="text-sm text-destructive">{state.status.message}</p>    </div>  );} type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>; function SubmitButton(props: ButtonProps & { children?: ReactNode }) {  const { state, meta, actions } = useSecretForm();  const { children, disabled, onClick, ...rest } = props;  const submitting = state.status.kind === "submitting";  return (    <Button      {...rest}      onClick={(e) => {        onClick?.(e);        if (!e.defaultPrevented) void actions.submit();      }}      disabled={disabled || !meta.canSubmit}    >      {submitting ? "Saving…" : children}    </Button>  );} // ---------------------------------------------------------------------------// Reveal-eye icon (used by ValueField when `revealable`)// --------------------------------------------------------------------------- function SecretVisibilityIcon(props: { revealed: boolean }) {  return props.revealed ? (    <svg      viewBox="0 0 16 16"      className="size-3.5"      fill="none"      stroke="currentColor"      strokeWidth="1.3"      strokeLinecap="round"      strokeLinejoin="round"    >      <path d="M2 2l12 12" />      <path d="M6.5 6.5a2 2 0 0 0 3 3" />      <path d="M3.5 5.5C2.3 6.7 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1 0 1.9-.3 2.7-.7" />      <path d="M10.7 10.7c2-1.4 3.3-3.2 3.8-3.7 0 0-2.5-5-6.5-5-.7 0-1.4.1-2 .4" />    </svg>  ) : (    <svg      viewBox="0 0 16 16"      className="size-3.5"      fill="none"      stroke="currentColor"      strokeWidth="1.3"      strokeLinecap="round"      strokeLinejoin="round"    >      <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />      <circle cx="8" cy="8" r="2" />    </svg>  );} // ---------------------------------------------------------------------------// Compound export// --------------------------------------------------------------------------- export const SecretForm = {  Provider: SecretFormProvider,  NameField,  IdField,  ValueField,  ErrorBanner,  SubmitButton,}; export type { SecretFormProviderProps };