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>
);
}