credential-form.tsx13.2 KBView on GitHub
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { INSTRUCTIONS_HELP, type SystemEntry } from './types';
import { useTRPC } from '@/providers/query-provider';
import { Loader2, Plus, Trash2 } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { ScopeSelect } from './scope-select';
import { useRef, useState } from 'react';
import { toast } from 'sonner';

type SecretRow = { rowId: string; key=[redacted]; value: string };

/**
 * Step 3 of the add dialog: store credentials for a system Cedar has no schema for.
 *
 * The secret bag is written whole on every save, because that is what the vault
 * does: an entry's secrets are replaced, never patched, so nobody can half-rotate a
 * credential set. Editing therefore asks for the values again, which is also the
 * only option available: reads never return them.
 */
export function CredentialForm({
  editing,
  isOrgAdmin,
  defaultScope = 'user',
  initialBackgroundAgents,
  onDone,
}: {
  editing?: SystemEntry;
  isOrgAdmin: boolean;
  defaultScope?: 'user' | 'org';
  /**
   * Overrides the entry's stored value. Set when the user flipped the row's toggle:
   * the vault replaces a bag rather than patching it, so changing that flag means
   * coming through this form with the secrets in hand.
   */
  initialBackgroundAgents?: boolean;
  onDone: () => void;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const nextRowId = useRef(0);

  const makeRow = (key = '', value = ''): SecretRow => {
    nextRowId.current += 1;
    return { rowId: `secret-${nextRowId.current}`, key, value };
  };

  const [name, setName] = useState(editing?.name ?? '');
  const [scope, setScope] = useState<'user' | 'org'>(editing?.scope ?? defaultScope);
  const [rows, setRows] = useState<SecretRow[]>(() =>
    editing?.credentialFields?.length
      ? editing.credentialFields.map((key) => makeRow(key))
      : [makeRow()],
  );
  const [instructions, setInstructions] = useState(editing?.instructions ?? '');
  const [backgroundAgents, setBackgroundAgents] = useState(
    initialBackgroundAgents ?? editing?.availableToBackgroundAgents ?? false,
  );
  /**
   * The entry already refreshes its own token. An upsert replaces metadata wholesale,
   * so this switch starting off would have quietly deleted that grant on any save,
   * including the one behind the row's background-agents toggle, which comes through
   * this form without the user ever looking at this section. The credential would then
   * work until its access token expired and fail with nothing pointing back here.
   */
  const storedRefresh = editing?.hasRefresh ?? false;
  const [refreshEnabled, setRefreshEnabled] = useState(storedRefresh);
  const [tokenUrl, setTokenUrl] = useState('');
  const [refreshTokenField, setRefreshTokenField] = useState('');
  const [clientIdField, setClientIdField] = useState('');
  const [clientSecretField, setClientSecretField] = useState('');

  const { mutateAsync: upsert, isPending: isSavingUser } = useMutation(
    trpc.credentialVault.upsert.mutationOptions(),
  );
  const { mutateAsync: upsertOrg, isPending: isSavingOrg } = useMutation(
    trpc.credentialVault.upsertOrg.mutationOptions(),
  );
  const isSaving = isSavingUser || isSavingOrg;

  const setRow = (rowId: string, patch: Partial<SecretRow>) =>
    setRows((current) => current.map((r) => (r.rowId === rowId ? { ...r, ...patch } : r)));

  const handleSave = async () => {
    const trimmedName = name.trim();
    if (!trimmedName) {
      toast.error('Give this entry a name');
      return;
    }
    if (!instructions.trim()) {
      toast.error('Tell the agent when to use this before saving');
      return;
    }

    const credentials: Record<string, string> = {};
    for (const row of rows) {
      const key=[redacted];
      if (!key) continue;
      if (!row.value) {
        toast.error(`Enter a value for "${key}". Cedar cannot show you the stored one back.`);
        return;
      }
      credentials[key] = row.value;
    }
    if (Object.keys(credentials).length === 0) {
      toast.error('Add at least one key and value');
      return;
    }

    if (refreshEnabled && (!tokenUrl.trim() || !refreshTokenField.trim())) {
      // Reads never return the stored refresh config either, so an existing grant has
      // to be retyped to survive the save. Refusing is the point: the alternative is
      // writing an entry with the refresh silently dropped.
      toast.error(
        storedRefresh
          ? 'This entry refreshes its own token. Cedar cannot show those settings back, so enter them again, or turn the switch off to remove the refresh.'
          : 'A refresh needs a token URL and the field holding the refresh token',
      );
      return;
    }

    const payload = {
      name: trimmedName,
      credentials,
      instructions: instructions.trim(),
      availableToBackgroundAgents: backgroundAgents,
      ...(refreshEnabled
        ? {
            refresh: {
              tokenUrl: tokenUrl.trim(),
              refreshTokenField: refreshTokenField.trim(),
              ...(clientIdField.trim() ? { clientIdField: clientIdField.trim() } : {}),
              ...(clientSecretField.trim() ? { clientSecretField: clientSecretField.trim() } : {}),
            },
          }
        : {}),
    };

    try {
      if (scope === 'org') await upsertOrg(payload);
      else await upsert(payload);
      void queryClient.invalidateQueries({ queryKey=[redacted] });
      onDone();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Could not save that entry');
    }
  };

  return (
    <div className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="credential-name">Name</Label>
        <Input
          id="credential-name"
          placeholder="e.g. xero-tenant"
          value={name}
          disabled={!!editing}
          onChange={(e) => setName(e.target.value)}
        />
        <p className="text-muted-foreground text-xs">
          Letters, numbers, dots, underscores and hyphens. Agents refer to the entry by this name.
        </p>
      </div>

      <ScopeSelect
        id="credential-scope"
        value={scope}
        onChange={setScope}
        isOrgAdmin={isOrgAdmin}
        disabled={!!editing}
        orgNote="Every member of your organization can use these secrets, unless they have an entry of their own with the same name."
      />

      <div className="space-y-2">
        <Label>Secrets</Label>
        {editing && (
          <p className="text-muted-foreground text-xs">
            Enter the values again. Cedar never returns a stored value, not even to you, so a save
            replaces the whole set.
          </p>
        )}
        <div className="space-y-2">
          {rows.map((row) => (
            <div key=[redacted] className="flex items-center gap-2">
              <Input
                aria-label="Key"
                placeholder="key, e.g. api_key"
                className="flex-1"
                value={row.key}
                onChange={(e) => setRow(row.rowId, { key=[redacted] })}
              />
              <Input
                aria-label="Value"
                type="password"
                placeholder="value"
                className="flex-1"
                value={row.value}
                onChange={(e) => setRow(row.rowId, { value: e.target.value })}
              />
              <Button
                type="button"
                variant="ghost"
                size="icon"
                className="text-muted-foreground hover:text-destructive flex-shrink-0"
                onClick={() => setRows((current) => current.filter((r) => r.rowId !== row.rowId))}
                disabled={rows.length === 1}
                title="Remove"
              >
                <Trash2 className="h-4 w-4" />
              </Button>
            </div>
          ))}
        </div>
        <Button
          type="button"
          variant="outline"
          size="sm"
          className="gap-1.5"
          onClick={() => setRows((current) => [...current, makeRow()])}
        >
          <Plus className="h-3.5 w-3.5" />
          Add another
        </Button>
      </div>

      <div className="space-y-3 rounded-lg border p-3">
        <div className="flex items-start justify-between gap-3">
          {/* Only phrasing content inside a label: a <p> here is invalid HTML. */}
          <label htmlFor="credential-refresh" className="min-w-0 space-y-0.5">
            <span className="block text-sm font-medium">This token has to be refreshed</span>
            <span className="text-muted-foreground block text-xs">
              For an OAuth token that expires. Cedar runs the refresh grant itself and caches the
              access token it gets back.
            </span>
          </label>
          <Switch
            id="credential-refresh"
            checked={refreshEnabled}
            onCheckedChange={setRefreshEnabled}
          />
        </div>

        {storedRefresh && !refreshEnabled && (
          <p className="rounded-md border border-amber-500/30 bg-amber-500/[0.06] px-2.5 py-2 text-xs text-amber-700 dark:text-amber-500">
            Saving now removes the refresh Cedar was running for this entry. Its access token will
            stop being renewed and the credential will fail once the current one expires.
          </p>
        )}

        {refreshEnabled && (
          <div className="space-y-3 border-t pt-3">
            {storedRefresh && (
              <p className="text-muted-foreground text-xs">
                This entry already refreshes itself, but Cedar never shows those settings back
                either, so enter them again to keep it working.
              </p>
            )}
            <div className="space-y-2">
              <Label htmlFor="credential-token-url">Token URL</Label>
              <Input
                id="credential-token-url"
                placeholder="https://login.example.com/oauth2/token"
                value={tokenUrl}
                onChange={(e) => setTokenUrl(e.target.value)}
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="credential-refresh-field">Which key holds the refresh token</Label>
              <Input
                id="credential-refresh-field"
                placeholder="refresh_token"
                value={refreshTokenField}
                onChange={(e) => setRefreshTokenField(e.target.value)}
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="credential-client-id-field">
                Which key holds the client ID{' '}
                <span className="text-muted-foreground font-normal">(optional)</span>
              </Label>
              <Input
                id="credential-client-id-field"
                placeholder="client_id"
                value={clientIdField}
                onChange={(e) => setClientIdField(e.target.value)}
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="credential-client-secret-field">
                Which key holds the client secret{' '}
                <span className="text-muted-foreground font-normal">(optional)</span>
              </Label>
              <Input
                id="credential-client-secret-field"
                placeholder="client_secret"
                value={clientSecretField}
                onChange={(e) => setClientSecretField(e.target.value)}
              />
            </div>
            <p className="text-muted-foreground text-xs">
              These name keys in the bag above, so rotating a secret stays one edit in one place.
            </p>
          </div>
        )}
      </div>

      <div className="flex items-start justify-between gap-3 rounded-lg border p-3">
        <label htmlFor="credential-background" className="min-w-0 space-y-0.5">
          <span className="block text-sm font-medium">Available to background agents</span>
          <span className="text-muted-foreground block text-xs">
            Off means chat only: Cedar uses this when you ask for it, and never in a scheduled or
            event-triggered run nobody is watching.
          </span>
        </label>
        <Switch
          id="credential-background"
          checked={backgroundAgents}
          onCheckedChange={setBackgroundAgents}
        />
      </div>

      <div className="space-y-2">
        <Label htmlFor="credential-instructions">When should the agent use this?</Label>
        <p className="text-muted-foreground text-xs">{INSTRUCTIONS_HELP}</p>
        <Textarea
          id="credential-instructions"
          rows={3}
          placeholder="e.g. Our Xero tenant. Use this to look up invoices and payment status for a customer."
          value={instructions}
          onChange={(e) => setInstructions(e.target.value)}
        />
      </div>

      <Button size="sm" onClick={handleSave} disabled={isSaving}>
        {isSaving && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
        {editing ? 'Save changes' : 'Store credentials'}
      </Button>
    </div>
  );
}