From 462d6772faad77472ef4f019892c1ad48bdeb9a2 Mon Sep 17 00:00:00 2001 From: Suchintan Date: Mon, 16 Feb 2026 23:43:25 -0500 Subject: [PATCH] Add frontend edit credential UI (#SKY-7883) (#4762) Co-authored-by: Suchintan Singh --- skyvern-frontend/src/api/types.ts | 1 + .../src/routes/credentials/CredentialItem.tsx | 37 ++++- .../routes/credentials/CredentialsModal.tsx | 127 +++++++++++++++--- .../credentials/useCredentialModalState.ts | 21 +++ skyvern/forge/sdk/routes/credentials.py | 46 +++++++ skyvern/forge/sdk/schemas/credentials.py | 15 ++- 6 files changed, 226 insertions(+), 21 deletions(-) diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index eb5074ac..fb49527b 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -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 = { diff --git a/skyvern-frontend/src/routes/credentials/CredentialItem.tsx b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx index 2857cbea..0c204dc7 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialItem.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialItem.tsx @@ -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) {

{credential.credential_id}

{credentialDetails} -
+
+ + + + + + Edit Credential + +
+
); } diff --git a/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx index 625eeb15..a0337abf 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialsModal.tsx @@ -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({ > - Add Credential + + {isEditMode ? "Edit Credential" : "Add Credential"} + + {isEditMode && ( + + + + For security, saved passwords and secrets are never retrieved. + Please re-enter all fields to update this credential. + + + )} {credentialContent} - diff --git a/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts b/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts index be336d85..c41324ef 100644 --- a/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts +++ b/skyvern-frontend/src/routes/credentials/useCredentialModalState.ts @@ -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 }; diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index af6a528e..0a3e686f 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -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, diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index 236f2854..f4689376 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -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")