improve credential ui/ux (#4474)
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user