Add frontend edit credential UI (#SKY-7883) (#4762)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan
2026-02-16 23:43:25 -05:00
committed by GitHub
parent bf8c7de8f9
commit 462d6772fa
6 changed files with 226 additions and 21 deletions

View File

@@ -501,6 +501,7 @@ export type Createv2TaskRequest = {
export type PasswordCredentialApiResponse = {
username: string;
totp_type: "authenticator" | "email" | "text" | "none";
totp_identifier?: string | null;
};
export type CreditCardCredentialApiResponse = {

View File

@@ -4,14 +4,27 @@ import {
isPasswordCredential,
isSecretCredential,
} from "@/api/types";
import { useState } from "react";
import { Pencil1Icon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DeleteCredentialButton } from "./DeleteCredentialButton";
import { CredentialsModal } from "./CredentialsModal";
import { credentialTypeToModalType } from "./useCredentialModalState";
type Props = {
credential: CredentialApiResponse;
};
function CredentialItem({ credential }: Props) {
const [editModalOpen, setEditModalOpen] = useState(false);
const credentialData = credential.credential;
const modalType = credentialTypeToModalType(credential.credential_type);
const getTotpTypeDisplay = (totpType: string) => {
switch (totpType) {
case "authenticator":
@@ -98,9 +111,31 @@ function CredentialItem({ credential }: Props) {
<p className="text-sm text-slate-400">{credential.credential_id}</p>
</div>
{credentialDetails}
<div className="ml-auto">
<div className="ml-auto flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="h-8 w-9"
onClick={() => setEditModalOpen(true)}
aria-label="Edit credential"
>
<Pencil1Icon className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Credential</TooltipContent>
</Tooltip>
</TooltipProvider>
<DeleteCredentialButton credential={credential} />
</div>
<CredentialsModal
isOpen={editModalOpen}
onOpenChange={setEditModalOpen}
editingCredential={credential}
overrideType={modalType}
/>
</div>
);
}

View File

@@ -9,18 +9,26 @@ import {
useCredentialModalState,
CredentialModalTypes,
} from "./useCredentialModalState";
import type { CredentialModalType } from "./useCredentialModalState";
import { PasswordCredentialContent } from "./PasswordCredentialContent";
import { SecretCredentialContent } from "./SecretCredentialContent";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CreditCardCredentialContent } from "./CreditCardCredentialContent";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCredentialRequest } from "@/api/types";
import {
CreateCredentialRequest,
CredentialApiResponse,
isPasswordCredential,
isCreditCardCredential,
isSecretCredential,
} from "@/api/types";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import { AxiosError } from "axios";
import { ReloadIcon } from "@radix-ui/react-icons";
import { InfoCircledIcon, ReloadIcon } from "@radix-ui/react-icons";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
const PASSWORD_CREDENTIAL_INITIAL_VALUES = {
@@ -70,24 +78,33 @@ type Props = {
/** Optional controlled mode: pass isOpen and onOpenChange to control modal state locally */
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** When provided, the modal opens in edit mode and pre-fills available fields */
editingCredential?: CredentialApiResponse;
/** Override the modal type (used in edit mode to set the correct form) */
overrideType?: CredentialModalType;
};
function CredentialsModal({
onCredentialCreated,
isOpen: controlledIsOpen,
onOpenChange: controlledOnOpenChange,
editingCredential,
overrideType,
}: Props) {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const {
isOpen: urlIsOpen,
type,
type: urlType,
setIsOpen: setUrlIsOpen,
} = useCredentialModalState();
const isEditMode = !!editingCredential;
// Use controlled props if provided, otherwise fall back to URL-based state
const isOpen = controlledIsOpen ?? urlIsOpen;
const setIsOpen = controlledOnOpenChange ?? setUrlIsOpen;
const type = overrideType ?? urlType;
const { data: credentials } = useCredentialsQuery({
page_size: 100,
});
@@ -101,10 +118,43 @@ function CredentialsModal({
SECRET_CREDENTIAL_INITIAL_VALUES,
);
// Set default name when modal opens
// Set default name when modal opens, or pre-populate fields in edit mode
useEffect(() => {
if (isOpen && credentials) {
const existingNames = credentials.map((cred) => cred.name);
if (!isOpen) return;
if (isEditMode) {
reset();
const cred = editingCredential.credential;
if (isPasswordCredential(cred)) {
setPasswordCredentialValues({
name: editingCredential.name,
username: cred.username,
password: "",
totp: "",
totp_type: cred.totp_type,
totp_identifier: cred.totp_identifier ?? "",
});
} else if (isCreditCardCredential(cred)) {
setCreditCardCredentialValues({
name: editingCredential.name,
cardNumber: "",
cardExpirationDate: "",
cardCode: "",
cardBrand: cred.brand,
cardHolderName: "",
});
} else if (isSecretCredential(cred)) {
setSecretCredentialValues({
name: editingCredential.name,
secretLabel: cred.secret_label ?? "",
secretValue: "",
});
}
return;
}
if (credentials) {
const existingNames = credentials.map((c) => c.name);
const defaultName = generateDefaultCredentialName(existingNames);
setPasswordCredentialValues((prev) => ({
@@ -120,7 +170,7 @@ function CredentialsModal({
name: defaultName,
}));
}
}, [isOpen, credentials]);
}, [isOpen, credentials, isEditMode, editingCredential]);
function reset() {
setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES);
@@ -157,6 +207,41 @@ function CredentialsModal({
},
});
const updateCredentialMutation = useMutation({
mutationFn: async (request: CreateCredentialRequest) => {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.post(
`/credentials/${editingCredential?.credential_id}/update`,
request,
);
return response.data;
},
onSuccess: () => {
reset();
setIsOpen(false);
queryClient.invalidateQueries({
queryKey: ["credentials"],
});
toast({
title: "Credential updated",
description: "Your credential has been updated successfully",
variant: "success",
});
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Error",
description: detail ? detail : error.message,
variant: "destructive",
});
},
});
const activeMutation = isEditMode
? updateCredentialMutation
: createCredentialMutation;
const handleSave = () => {
const name =
type === CredentialModalTypes.PASSWORD
@@ -187,7 +272,7 @@ function CredentialsModal({
});
return;
}
createCredentialMutation.mutate({
activeMutation.mutate({
name,
credential_type: "password",
credential: {
@@ -242,7 +327,7 @@ function CredentialsModal({
}
// remove all spaces from the card number
const number = creditCardCredentialValues.cardNumber.replace(/\s/g, "");
createCredentialMutation.mutate({
activeMutation.mutate({
name,
credential_type: "credit_card",
credential: {
@@ -267,7 +352,7 @@ function CredentialsModal({
return;
}
createCredentialMutation.mutate({
activeMutation.mutate({
name,
credential_type: "secret",
credential: {
@@ -315,18 +400,26 @@ function CredentialsModal({
>
<DialogContent className="w-[700px] max-w-[700px]">
<DialogHeader>
<DialogTitle className="font-bold">Add Credential</DialogTitle>
<DialogTitle className="font-bold">
{isEditMode ? "Edit Credential" : "Add Credential"}
</DialogTitle>
</DialogHeader>
{isEditMode && (
<Alert>
<InfoCircledIcon className="size-4" />
<AlertDescription>
For security, saved passwords and secrets are never retrieved.
Please re-enter all fields to update this credential.
</AlertDescription>
</Alert>
)}
{credentialContent}
<DialogFooter>
<Button
onClick={handleSave}
disabled={createCredentialMutation.isPending}
>
{createCredentialMutation.isPending ? (
<Button onClick={handleSave} disabled={activeMutation.isPending}>
{activeMutation.isPending ? (
<ReloadIcon className="mr-2 size-4 animate-spin" />
) : null}
Save
{isEditMode ? "Update" : "Save"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -57,4 +57,25 @@ function useCredentialModalState(): ReturnType {
};
}
/**
* Convert a backend credential_type ("password" | "credit_card" | "secret")
* to the modal type used by CredentialsModal ("password" | "credit-card" | "secret").
*/
export function credentialTypeToModalType(
credentialType: "password" | "credit_card" | "secret",
): CredentialModalType {
switch (credentialType) {
case "password":
return CredentialModalTypes.PASSWORD;
case "credit_card":
return CredentialModalTypes.CREDIT_CARD;
case "secret":
return CredentialModalTypes.SECRET;
default: {
const _exhaustive: never = credentialType;
throw new Error(`Unhandled credential type: ${_exhaustive}`);
}
}
}
export { useCredentialModalState };

View File

@@ -1,3 +1,30 @@
"""Credential management API endpoints.
SECURITY INVARIANT — NO RAW CREDENTIAL RETRIEVAL
=================================================
Credential endpoints must NEVER return sensitive credential data (passwords,
TOTP secrets, full card numbers, CVVs, expiration dates, card holder names,
or secret values) in any API response. The only fields that may be returned
are non-sensitive metadata:
- Password credentials: ``username``, ``totp_type``, ``totp_identifier``
- Credit card credentials: ``last_four``, ``brand``
- Secret credentials: ``secret_label``
This is enforced by the ``*CredentialResponse`` Pydantic models and the
``_convert_to_response()`` helper. When adding new credential types or
modifying existing ones, ensure that:
1. The response model never includes the raw secret material.
2. The ``_convert_to_response()`` function only maps non-sensitive fields.
3. No endpoint (including ``get_credential`` and ``get_credentials``) ever
fetches and returns the decrypted secret from the vault.
Violating this invariant would allow any caller with a valid API key to
exfiltrate stored passwords, card numbers, and secrets — which is the
exact threat the vault architecture is designed to prevent.
"""
import json
import structlog
@@ -442,6 +469,13 @@ async def get_credential(
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CredentialResponse:
"""Return non-sensitive metadata for a single credential.
SECURITY: This endpoint intentionally does NOT return the raw secret
material (password, card number, CVV, secret value, etc.). Only
non-sensitive fields are included in the response. See the module
docstring for the full security invariant.
"""
credential = await app.DATABASE.get_credential(
credential_id=credential_id, organization_id=current_org.organization_id
)
@@ -493,6 +527,11 @@ async def get_credentials(
openapi_extra={"x-fern-sdk-parameter-name": "page_size"},
),
) -> list[CredentialResponse]:
"""Return non-sensitive metadata for all credentials (paginated).
SECURITY: Like ``get_credential``, this endpoint never returns raw secret
material. See the module docstring for the full security invariant.
"""
credentials = await app.DATABASE.get_credentials(current_org.organization_id, page=page, page_size=page_size)
return [_convert_to_response(credential) for credential in credentials]
@@ -825,6 +864,13 @@ async def _get_credential_vault_service() -> CredentialVaultService:
def _convert_to_response(credential: Credential) -> CredentialResponse:
"""Convert an internal ``Credential`` to a safe API response.
SECURITY: This function must ONLY copy non-sensitive metadata into the
response. Never include passwords, TOTP secrets, full card numbers, CVVs,
expiration dates, card holder names, or secret values. See the module
docstring for the full security invariant.
"""
if credential.credential_type == CredentialType.PASSWORD:
credential_response = PasswordCredentialResponse(
username=credential.username or credential.credential_id,

View File

@@ -28,7 +28,10 @@ class TotpType(StrEnum):
class PasswordCredentialResponse(BaseModel):
"""Response model for password credentials, containing only the username."""
"""Response model for password credentials — non-sensitive fields only.
SECURITY: Must NEVER include password, TOTP secret, or TOTP identifier.
"""
username: str = Field(..., description="The username associated with the credential", examples=["user@example.com"])
totp_type: TotpType = Field(
@@ -44,14 +47,20 @@ class PasswordCredentialResponse(BaseModel):
class CreditCardCredentialResponse(BaseModel):
"""Response model for credit card credentials, containing only the last four digits and brand."""
"""Response model for credit card credentials — non-sensitive fields only.
SECURITY: Must NEVER include full card number, CVV, expiration date, or card holder name.
"""
last_four: str = Field(..., description="Last four digits of the credit card number", examples=["1234"])
brand: str = Field(..., description="Brand of the credit card", examples=["visa"])
class SecretCredentialResponse(BaseModel):
"""Response model for secret credentials."""
"""Response model for secret credentials — non-sensitive fields only.
SECURITY: Must NEVER include the secret_value.
"""
secret_label: str | None = Field(default=None, description="Optional label for the stored secret")