improve credential ui/ux (#4474)

This commit is contained in:
Celal Zamanoglu
2026-01-17 00:29:27 +03:00
committed by GitHub
parent 7c2167616b
commit 089fafcf8d
4 changed files with 134 additions and 57 deletions

View File

@@ -67,12 +67,27 @@ function generateDefaultCredentialName(existingNames: string[]): string {
type Props = { type Props = {
onCredentialCreated?: (id: string) => void; onCredentialCreated?: (id: string) => void;
/** Optional controlled mode: pass isOpen and onOpenChange to control modal state locally */
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}; };
function CredentialsModal({ onCredentialCreated }: Props) { function CredentialsModal({
onCredentialCreated,
isOpen: controlledIsOpen,
onOpenChange: controlledOnOpenChange,
}: Props) {
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOpen, type, setIsOpen } = useCredentialModalState(); const {
isOpen: urlIsOpen,
type,
setIsOpen: setUrlIsOpen,
} = useCredentialModalState();
// Use controlled props if provided, otherwise fall back to URL-based state
const isOpen = controlledIsOpen ?? urlIsOpen;
const setIsOpen = controlledOnOpenChange ?? setUrlIsOpen;
const { data: credentials } = useCredentialsQuery({ const { data: credentials } = useCredentialsQuery({
page_size: 100, page_size: 100,
}); });
@@ -120,6 +135,7 @@ function CredentialsModal({ onCredentialCreated }: Props) {
return response.data; return response.data;
}, },
onSuccess: (data) => { onSuccess: (data) => {
reset();
setIsOpen(false); setIsOpen(false);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["credentials"], queryKey: ["credentials"],

View File

@@ -10,11 +10,8 @@ import { useCredentialsQuery } from "../hooks/useCredentialsQuery";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { WorkflowParameterValueType } from "../types/workflowTypes"; import { WorkflowParameterValueType } from "../types/workflowTypes";
import { PlusIcon } from "@radix-ui/react-icons"; import { PlusIcon } from "@radix-ui/react-icons";
import {
CredentialModalTypes,
useCredentialModalState,
} from "@/routes/credentials/useCredentialModalState";
import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; import { CredentialsModal } from "@/routes/credentials/CredentialsModal";
import { useState } from "react";
type Props = { type Props = {
value: string; value: string;
@@ -25,7 +22,8 @@ function CredentialParameterSourceSelector({ value, onChange }: Props) {
const { data: credentials, isFetching } = useCredentialsQuery({ const { data: credentials, isFetching } = useCredentialsQuery({
page_size: 100, // Reasonable limit for dropdown selector page_size: 100, // Reasonable limit for dropdown selector
}); });
const { setIsOpen, setType } = useCredentialModalState(); // Use local state for modal to avoid conflicts with other CredentialsModal instances
const [isModalOpen, setIsModalOpen] = useState(false);
const { parameters: workflowParameters } = useWorkflowParametersStore(); const { parameters: workflowParameters } = useWorkflowParametersStore();
const workflowParametersOfTypeCredentialId = workflowParameters.filter( const workflowParametersOfTypeCredentialId = workflowParameters.filter(
(parameter) => (parameter) =>
@@ -65,8 +63,7 @@ function CredentialParameterSourceSelector({ value, onChange }: Props) {
value={value} value={value}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "new") { if (value === "new") {
setIsOpen(true); setIsModalOpen(true);
setType(CredentialModalTypes.PASSWORD);
return; return;
} }
onChange(value); onChange(value);
@@ -90,9 +87,11 @@ function CredentialParameterSourceSelector({ value, onChange }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<CredentialsModal <CredentialsModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
onCredentialCreated={(id) => { onCredentialCreated={(id) => {
onChange(id); onChange(id);
setIsOpen(false); setIsModalOpen(false);
}} }}
/> />
</> </>

View File

@@ -11,7 +11,7 @@ import CloudContext from "@/store/CloudContext";
import { useContext, useMemo } from "react"; import { useContext, useMemo } from "react";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { CredentialsModal } from "@/routes/credentials/CredentialsModal"; import { CredentialsModal } from "@/routes/credentials/CredentialsModal";
import { PlusIcon } from "@radix-ui/react-icons"; import { ExclamationTriangleIcon, PlusIcon } from "@radix-ui/react-icons";
import { import {
CredentialModalTypes, CredentialModalTypes,
useCredentialModalState, useCredentialModalState,
@@ -19,7 +19,12 @@ import {
import { useNodes } from "@xyflow/react"; import { useNodes } from "@xyflow/react";
import { AppNode } from ".."; import { AppNode } from "..";
import { isLoginNode } from "./types"; import { isLoginNode } from "./types";
import { parameterIsSkyvernCredential } from "../../types"; import {
parameterIsSkyvernCredential,
parameterIsBitwardenCredential,
parameterIsOnePasswordCredential,
parameterIsAzureVaultCredential,
} from "../../types";
type Props = { type Props = {
nodeId: string; nodeId: string;
@@ -57,27 +62,52 @@ function LoginBlockCredentialSelector({ nodeId, value, onChange }: Props) {
parameter.parameterType === "credential" || parameter.parameterType === "credential" ||
parameter.parameterType === "onepassword", parameter.parameterType === "onepassword",
); );
const credentialInputParameters = workflowParameters.filter(
(parameter) =>
parameter.parameterType === "workflow" &&
parameter.dataType === "credential_id",
);
const isCloud = useContext(CloudContext); const isCloud = useContext(CloudContext);
const { data: credentials = [], isFetching } = useCredentialsQuery({ const { data: credentials = [], isFetching } = useCredentialsQuery({
enabled: isCloud, enabled: isCloud,
page_size: 100, page_size: 100,
}); });
// Get the set of credential IDs that are in the vault
const credentialIdsInVault = useMemo(
() => new Set(credentials.map((c) => c.credential_id)),
[credentials],
);
// Determine which credential is currently selected (by credential_id) // Determine which credential is currently selected (by credential_id)
// This must be before the early return to comply with React hooks rules // This handles multiple cases:
// 1. Skyvern credential parameters (have credentialId)
// 2. Workflow input parameters with credential_id type and default value
const selectedCredentialId = useMemo(() => { const selectedCredentialId = useMemo(() => {
if (!value) return undefined; if (!value) return undefined;
const parameter = credentialParameters.find((p) => p.key === value);
if (parameter && parameterIsSkyvernCredential(parameter)) { // Check if it's a credential parameter
return parameter.credentialId; const credentialParam = credentialParameters.find((p) => p.key === value);
if (credentialParam && parameterIsSkyvernCredential(credentialParam)) {
return credentialParam.credentialId;
} }
// Check if it's a workflow input parameter with credential_id type and default value
const workflowParam = workflowParameters.find(
(p) =>
p.parameterType === "workflow" &&
p.key === value &&
p.dataType === "credential_id" &&
typeof p.defaultValue === "string" &&
p.defaultValue,
);
if (workflowParam && workflowParam.parameterType === "workflow") {
return workflowParam.defaultValue as string;
}
return undefined; return undefined;
}, [value, credentialParameters]); }, [value, credentialParameters, workflowParameters]);
// Check if the selected credential is missing (deleted)
const isCredentialMissing = useMemo(() => {
if (!selectedCredentialId) return false;
return !credentialIdsInVault.has(selectedCredentialId);
}, [selectedCredentialId, credentialIdsInVault]);
if (isCloud && isFetching) { if (isCloud && isFetching) {
return <Skeleton className="h-8 w-full" />; return <Skeleton className="h-8 w-full" />;
@@ -86,48 +116,36 @@ function LoginBlockCredentialSelector({ nodeId, value, onChange }: Props) {
const credentialOptions = credentials.map((credential) => ({ const credentialOptions = credentials.map((credential) => ({
label: credential.name, label: credential.name,
value: credential.credential_id, value: credential.credential_id,
type: "credential", type: "credential" as const,
})); }));
// Get the set of credential IDs that are in the vault // Only show non-Skyvern credential parameters (Bitwarden, 1Password, Azure Vault)
const credentialIdsInVault = new Set(credentials.map((c) => c.credential_id)); // Skyvern credential parameters should never be shown - the actual credential is displayed directly
const externalVaultParameterOptions = credentialParameters
// Filter credential parameters to only show those that reference credentials
// NOT in the vault (e.g., Bitwarden, 1Password, Azure Vault credentials)
// Skyvern credential parameters are excluded because the actual credential is already shown
const filteredCredentialParameterOptions = credentialParameters
.filter((parameter) => { .filter((parameter) => {
// Never show Skyvern credential parameters
if (parameterIsSkyvernCredential(parameter)) { if (parameterIsSkyvernCredential(parameter)) {
// Don't show Skyvern credential parameters if the credential is in the vault return false;
return !credentialIdsInVault.has(parameter.credentialId);
} }
// Show non-Skyvern credential parameters (Bitwarden, 1Password, etc.) // Show Bitwarden, 1Password, Azure Vault credential parameters
return true; return (
parameterIsBitwardenCredential(parameter) ||
parameterIsOnePasswordCredential(parameter) ||
parameterIsAzureVaultCredential(parameter)
);
}) })
.map((parameter) => ({ .map((parameter) => ({
label: parameter.key, label: parameter.key,
value: parameter.key, value: parameter.key,
type: "parameter", type: "parameter" as const,
})); }));
const credentialInputParameterOptions = credentialInputParameters.map( const options = [...credentialOptions, ...externalVaultParameterOptions];
(parameter) => ({
label: parameter.key,
value: parameter.key,
type: "parameter",
}),
);
const options = [
...credentialOptions,
...filteredCredentialParameterOptions,
...credentialInputParameterOptions,
];
return ( return (
<> <>
<Select <Select
value={selectedCredentialId ?? value} value={isCredentialMissing ? undefined : selectedCredentialId ?? value}
onValueChange={(newValue) => { onValueChange={(newValue) => {
if (newValue === "new") { if (newValue === "new") {
setIsOpen(true); setIsOpen(true);
@@ -209,8 +227,21 @@ function LoginBlockCredentialSelector({ nodeId, value, onChange }: Props) {
onChange?.(parameterKeyToUse); onChange?.(parameterKeyToUse);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger
<SelectValue placeholder="Select a credential" /> className={
isCredentialMissing
? "w-full border-red-500 text-red-500"
: "w-full"
}
>
{isCredentialMissing ? (
<div className="flex items-center gap-2 text-red-500">
<ExclamationTriangleIcon className="size-4" />
<span>Credential not found</span>
</div>
) : (
<SelectValue placeholder="Select a credential" />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option) => ( {options.map((option) => (

View File

@@ -2,6 +2,10 @@ import { MultiSelect } from "@/components/ui/multi-select";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { HelpTooltip } from "@/components/HelpTooltip"; import { HelpTooltip } from "@/components/HelpTooltip";
import { helpTooltips } from "../../helpContent"; import { helpTooltips } from "../../helpContent";
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
import { useContext, useMemo } from "react";
import CloudContext from "@/store/CloudContext";
import { parameterIsSkyvernCredential } from "../../types";
type Props = { type Props = {
availableOutputParameters: Array<string>; availableOutputParameters: Array<string>;
@@ -14,17 +18,44 @@ function ParametersMultiSelect({
parameters, parameters,
onParametersChange, onParametersChange,
}: Props) { }: Props) {
const isCloud = useContext(CloudContext);
const { parameters: workflowParameters } = useWorkflowParametersStore(); const { parameters: workflowParameters } = useWorkflowParametersStore();
// Fetch credentials to check for orphaned Skyvern credential parameters
const { data: credentials = [], isSuccess } = useCredentialsQuery({
enabled: isCloud,
page_size: 100,
});
// Get the set of credential IDs that exist in the vault
const credentialIdsInVault = useMemo(
() => new Set(credentials.map((c) => c.credential_id)),
[credentials],
);
const keys = workflowParameters const keys = workflowParameters
.map((parameter) => parameter.key) .map((parameter) => parameter.key)
.concat(availableOutputParameters); .concat(availableOutputParameters);
const options = keys.map((key) => { // Build options with warning labels for orphaned Skyvern credential parameters
return { const options = useMemo(() => {
label: key, return keys.map((key) => {
value: key, const param = workflowParameters.find((p) => p.key === key);
};
}); // Check if this is an orphaned Skyvern credential parameter
const isOrphanedCredential =
isSuccess &&
param &&
param.parameterType === "credential" &&
parameterIsSkyvernCredential(param) &&
!credentialIdsInVault.has(param.credentialId);
return {
label: isOrphanedCredential ? `⚠️ ${key} (missing credential)` : key,
value: key,
};
});
}, [keys, workflowParameters, isSuccess, credentialIdsInVault]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">