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