mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 07:30:20 +00:00
merge main
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { faCircle, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faFolder, faKey, faLayerGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
@@ -79,7 +79,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const [debouncedValue] = useDebounce(value, 500);
|
||||
const [debouncedValue] = useDebounce(value, 100);
|
||||
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
|
||||
@@ -142,7 +142,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
const suggestions = useMemo(() => {
|
||||
if (!isPopupOpen) return [];
|
||||
// reset highlight whenever recomputation happens
|
||||
setHighlightedIndex(-1);
|
||||
setHighlightedIndex(0);
|
||||
const suggestionsArr: ReferenceItem[] = [];
|
||||
const predicate = suggestionSource.predicate.toLowerCase();
|
||||
|
||||
@@ -298,17 +298,21 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full max-w-60 flex-col items-center justify-center rounded-md text-white"
|
||||
className="h-full w-full flex-col items-center justify-center rounded-md text-white"
|
||||
ref={popoverContentRef}
|
||||
>
|
||||
{suggestions.map((item, i) => {
|
||||
let entryIcon;
|
||||
let subText;
|
||||
if (item.type === ReferenceType.SECRET) {
|
||||
entryIcon = faKey;
|
||||
entryIcon = <FontAwesomeIcon icon={faKey} className="text-bunker-300" />;
|
||||
subText = "Secret";
|
||||
} else if (item.type === ReferenceType.ENVIRONMENT) {
|
||||
entryIcon = faCircle;
|
||||
entryIcon = <FontAwesomeIcon icon={faLayerGroup} className="text-green-700" />;
|
||||
subText = "Environment";
|
||||
} else {
|
||||
entryIcon = faFolder;
|
||||
entryIcon = <FontAwesomeIcon icon={faFolder} className="text-yellow-700" />;
|
||||
subText = "Folder";
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -327,22 +331,22 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(i)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="flex items-center justify-between border-mineshaft-600 text-left"
|
||||
className="flex w-full items-center justify-between border-mineshaft-600 text-left"
|
||||
key={`secret-reference-secret-${i + 1}`}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
highlightedIndex === i ? "bg-gray-600" : ""
|
||||
} text-md relative mb-0.5 flex w-full cursor-pointer select-none items-center justify-between rounded-md px-2 py-2 outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-500`}
|
||||
highlightedIndex === i ? "bg-mineshaft-500" : ""
|
||||
} text-md relative flex w-full cursor-pointer select-none items-center justify-between px-2 py-2 outline-none transition-all hover:bg-mineshaft-700 data-[highlighted]:bg-mineshaft-700`}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="flex items-center text-yellow-700">
|
||||
<FontAwesomeIcon
|
||||
icon={entryIcon}
|
||||
size={item.type === ReferenceType.ENVIRONMENT ? "xs" : "1x"}
|
||||
/>
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<div className="mt-1 flex items-center">{entryIcon}</div>
|
||||
<div className="text-md w-10/12 truncate text-left">
|
||||
{item.label}
|
||||
<div className="mb-[0.1rem] text-xs leading-3 text-bunker-400">
|
||||
{subText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-md w-10/12 truncate text-left">{item.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,6 +172,9 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case ChecklyConnectionMethod.ApiKey:
|
||||
return { name: "API Key", icon: faKey };
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
case AzureAppConfigurationConnectionMethod.ClientSecret:
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||
return { name: "Client Secret", icon: faKey };
|
||||
default:
|
||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||
|
||||
@@ -51,6 +51,7 @@ export type TServerConfig = {
|
||||
invalidatingCache: boolean;
|
||||
fipsEnabled: boolean;
|
||||
envOverrides?: Record<string, string>;
|
||||
paramsFolderSecretDetectionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type TUpdateServerConfigDTO = {
|
||||
|
||||
@@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
|
||||
|
||||
export enum AzureAppConfigurationConnectionMethod {
|
||||
OAuth = "oauth"
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
}
|
||||
|
||||
export type TAzureAppConfigurationConnection = TRootAppConnection & {
|
||||
app: AppConnection.AzureAppConfiguration;
|
||||
} & {
|
||||
method: AzureAppConfigurationConnectionMethod.OAuth;
|
||||
credentials: {
|
||||
code: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
method: AzureAppConfigurationConnectionMethod.OAuth;
|
||||
credentials: {
|
||||
code: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: AzureAppConfigurationConnectionMethod.ClientSecret;
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tenantId: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
|
||||
|
||||
export enum AzureDevOpsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
AccessToken = "access-token"
|
||||
AccessToken = "access-token",
|
||||
ClientSecret = "client-secret"
|
||||
}
|
||||
|
||||
export type TAzureDevOpsConnection = TRootAppConnection & {
|
||||
@@ -24,4 +25,13 @@ export type TAzureDevOpsConnection = TRootAppConnection & {
|
||||
orgName: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: AzureDevOpsConnectionMethod.ClientSecret;
|
||||
credentials: {
|
||||
clientSecret: string;
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
orgName: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,13 +2,24 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
|
||||
|
||||
export enum AzureKeyVaultConnectionMethod {
|
||||
OAuth = "oauth"
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
}
|
||||
|
||||
export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & {
|
||||
method: AzureKeyVaultConnectionMethod.OAuth;
|
||||
credentials: {
|
||||
code: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
};
|
||||
export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & (
|
||||
| {
|
||||
method: AzureKeyVaultConnectionMethod.OAuth;
|
||||
credentials: {
|
||||
code: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: AzureKeyVaultConnectionMethod.ClientSecret;
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tenantId: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub
|
||||
method: GitHubConnectionMethod.OAuth;
|
||||
credentials: {
|
||||
code: string;
|
||||
host?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -18,6 +19,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub
|
||||
credentials: {
|
||||
code: string;
|
||||
installationId: string;
|
||||
host?: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -281,7 +281,8 @@ export const useUpdateProject = () => {
|
||||
newProjectDescription,
|
||||
newSlug,
|
||||
secretSharing,
|
||||
showSnapshotsLegacy
|
||||
showSnapshotsLegacy,
|
||||
secretDetectionIgnoreValues
|
||||
}) => {
|
||||
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
|
||||
`/api/v1/workspace/${projectID}`,
|
||||
@@ -290,7 +291,8 @@ export const useUpdateProject = () => {
|
||||
description: newProjectDescription,
|
||||
slug: newSlug,
|
||||
secretSharing,
|
||||
showSnapshotsLegacy
|
||||
showSnapshotsLegacy,
|
||||
secretDetectionIgnoreValues
|
||||
}
|
||||
);
|
||||
return data.workspace;
|
||||
|
||||
@@ -40,6 +40,7 @@ export type Workspace = {
|
||||
hasDeleteProtection: boolean;
|
||||
secretSharing: boolean;
|
||||
showSnapshotsLegacy: boolean;
|
||||
secretDetectionIgnoreValues: string[];
|
||||
};
|
||||
|
||||
export type WorkspaceEnv = {
|
||||
@@ -81,6 +82,7 @@ export type UpdateProjectDTO = {
|
||||
newSlug?: string;
|
||||
secretSharing?: boolean;
|
||||
showSnapshotsLegacy?: boolean;
|
||||
secretDetectionIgnoreValues?: string[];
|
||||
};
|
||||
|
||||
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
|
||||
|
||||
@@ -8,10 +8,13 @@ import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
|
||||
import { Banner } from "@app/components/page-frames/Banner";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useFetchServerStatus } from "@app/hooks/api";
|
||||
|
||||
import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner";
|
||||
import { Navbar } from "./components/NavBar";
|
||||
import { OrgSidebar } from "./components/OrgSidebar";
|
||||
import { RedisBanner } from "./components/RedisBanner";
|
||||
import { SmtpBanner } from "./components/SmtpBanner";
|
||||
|
||||
export const OrganizationLayout = () => {
|
||||
const { config } = useServerConfig();
|
||||
@@ -27,6 +30,8 @@ export const OrganizationLayout = () => {
|
||||
|
||||
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
|
||||
|
||||
const { data: serverDetails, isLoading } = useFetchServerStatus();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner />
|
||||
@@ -34,6 +39,8 @@ export const OrganizationLayout = () => {
|
||||
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
|
||||
>
|
||||
<Navbar />
|
||||
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
|
||||
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
|
||||
{!window.isSecureContext && <InsecureConnectionBanner />}
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
<OrgSidebar isHidden={isInsideProject} />
|
||||
|
||||
+5
-32
@@ -1,37 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { OrgAlertBanner } from "../OrgAlertBanner";
|
||||
|
||||
export const InsecureConnectionBanner = () => {
|
||||
const [isAcknowledged, setIsAcknowledged] = useState(
|
||||
localStorage.getItem("insecureConnectionAcknowledged") ?? false
|
||||
);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsAcknowledged(true);
|
||||
localStorage.setItem("insecureConnectionAcknowledged", "true");
|
||||
};
|
||||
|
||||
if (isAcknowledged) return null;
|
||||
|
||||
return (
|
||||
<div className="flex w-screen items-start border-b border-red-900 bg-red-700 px-2 py-1 font-inter text-sm text-mineshaft-200">
|
||||
<FontAwesomeIcon className="ml-3.5 mt-1" icon={faWarning} />
|
||||
<span className="mx-1 ml-2 mt-[0.04rem]">
|
||||
Your connection to this Infisical instance is not secured via HTTPS. Some features may not
|
||||
behave as expected.
|
||||
</span>
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
colorSchema="danger"
|
||||
onClick={handleDismiss}
|
||||
ariaLabel="Dismiss banner"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<OrgAlertBanner
|
||||
text="Your connection to this Infisical instance is not secured via HTTPS. Some features may not
|
||||
behave as expected."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { faArrowUpRightFromSquare, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export const OrgAlertBanner = ({ text, link }: Props) => {
|
||||
const [isDismissed, setIsDismissed] = useToggle(false);
|
||||
|
||||
if (isDismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center border-b border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
|
||||
<FontAwesomeIcon icon={faWarning} className="mr-2.5 text-base text-yellow" />
|
||||
{text}{" "}
|
||||
{link && (
|
||||
<>
|
||||
Learn how to configure it
|
||||
<a
|
||||
href={link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="cursor-pointer pl-1 text-yellow-500 underline underline-offset-2 duration-100 group-hover:text-mineshaft-100 group-hover:decoration-mineshaft-100">
|
||||
here
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
className="ml-0.5 mt-0.5 text-yellow group-hover:text-mineshaft-100"
|
||||
icon={faArrowUpRightFromSquare}
|
||||
size="xs"
|
||||
/>
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
className="ml-auto p-0 text-yellow-200"
|
||||
ariaLabel="Dismiss banner"
|
||||
variant="plain"
|
||||
onClick={() => setIsDismissed.on()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./OrgAlertBanner";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { OrgAlertBanner } from "../OrgAlertBanner";
|
||||
|
||||
export const RedisBanner = () => {
|
||||
return (
|
||||
<OrgAlertBanner
|
||||
text="Attention: Updated versions of Infisical now require Redis for full functionality."
|
||||
link="https://infisical.com/docs/self-hosting/configuration/requirements#redis"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./RedisBanner";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { OrgAlertBanner } from "../OrgAlertBanner";
|
||||
|
||||
export const SmtpBanner = () => {
|
||||
return (
|
||||
<OrgAlertBanner
|
||||
text="Attention: SMTP has not been configured for this instance."
|
||||
link="https://infisical.com/docs/self-hosting/configuration/envars#email-service"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SmtpBanner";
|
||||
@@ -10,8 +10,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||
import { useProjectPermission, useWorkspace } from "@app/context";
|
||||
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretScanningFindingActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useGetSecretScanningUnresolvedFindingCount } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
|
||||
|
||||
@@ -19,6 +26,22 @@ export const SecretScanningLayout = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { assumedPrivilegeDetails } = useProjectPermission();
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data: unresolvedFindings } = useGetSecretScanningUnresolvedFindingCount(
|
||||
currentWorkspace.id,
|
||||
{
|
||||
enabled:
|
||||
subscription.secretScanning &&
|
||||
permission.can(
|
||||
ProjectPermissionSecretScanningFindingActions.Read,
|
||||
ProjectPermissionSub.SecretScanningFindings
|
||||
),
|
||||
refetchInterval: 30000
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
@@ -63,11 +86,16 @@ export const SecretScanningLayout = () => {
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="mx-1 flex w-full gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} />
|
||||
</div>
|
||||
Findings
|
||||
<span>Findings</span>
|
||||
{Boolean(unresolvedFindings) && (
|
||||
<Badge variant="primary" className="ml-auto mr-2 h-min">
|
||||
{unresolvedFindings}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Input, Spinner } from "@app/components/v2";
|
||||
import { Button, ContentLoader, Input } from "@app/components/v2";
|
||||
import { envConfig } from "@app/config/env";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { useToggle } from "@app/hooks";
|
||||
@@ -329,9 +329,8 @@ export const PasswordStep = ({
|
||||
|
||||
if (hasExchangedPrivateKey) {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Spinner />
|
||||
<p className="text-white opacity-80">Loading, please wait</p>
|
||||
<div className="fixed left-0 top-0 h-screen w-screen bg-bunker-800">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Spinner } from "@app/components/v2";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { useGetMyDuplicateAccount } from "@app/hooks/api";
|
||||
|
||||
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
|
||||
import { SelectOrganizationSection } from "./SelectOrgSection";
|
||||
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Spinner />
|
||||
<p className="text-white opacity-80">Loading, please wait</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectOrganizationPage = () => {
|
||||
const duplicateAccounts = useGetMyDuplicateAccount();
|
||||
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
|
||||
|
||||
if (duplicateAccounts.isPending) {
|
||||
return <LoadingScreen />;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-bunker-800">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Mfa } from "@app/components/auth/Mfa";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Spinner } from "@app/components/v2";
|
||||
import { Button, ContentLoader, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useToggle } from "@app/hooks";
|
||||
@@ -29,15 +29,6 @@ import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
|
||||
import { navigateUserToOrg } from "../LoginPage/Login.utils";
|
||||
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Spinner />
|
||||
<p className="text-white opacity-80">Loading, please wait</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectOrganizationSection = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -228,7 +219,11 @@ export const SelectOrganizationSection = () => {
|
||||
!user ||
|
||||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
|
||||
) {
|
||||
return <LoadingScreen />;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-bunker-800">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+6
-4
@@ -92,9 +92,9 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
|
||||
case AppConnection.GCP:
|
||||
return <GcpConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.AzureKeyVault:
|
||||
return <AzureKeyVaultConnectionForm />;
|
||||
return <AzureKeyVaultConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.AzureAppConfiguration:
|
||||
return <AzureAppConfigurationConnectionForm />;
|
||||
return <AzureAppConfigurationConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Databricks:
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Humanitec:
|
||||
@@ -200,9 +200,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
case AppConnection.GCP:
|
||||
return <GcpConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
|
||||
case AppConnection.AzureKeyVault:
|
||||
return <AzureKeyVaultConnectionForm appConnection={appConnection} />;
|
||||
return <AzureKeyVaultConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
|
||||
case AppConnection.AzureAppConfiguration:
|
||||
return <AzureAppConfigurationConnectionForm appConnection={appConnection} />;
|
||||
return (
|
||||
<AzureAppConfigurationConnectionForm appConnection={appConnection} onSubmit={onSubmit} />
|
||||
);
|
||||
case AppConnection.Databricks:
|
||||
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Humanitec:
|
||||
|
||||
+143
-39
@@ -20,19 +20,83 @@ import {
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureAppConfigurationConnection;
|
||||
onSubmit: (formData: ClientSecretForm) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAppConnectionFieldsSchema.extend({
|
||||
const baseSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.AzureAppConfiguration),
|
||||
method: z.nativeEnum(AzureAppConfigurationConnectionMethod),
|
||||
tenantId: z.string().trim().optional()
|
||||
method: z.nativeEnum(AzureAppConfigurationConnectionMethod)
|
||||
});
|
||||
|
||||
const oauthSchema = baseSchema.extend({
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required"),
|
||||
method: z.literal(AzureAppConfigurationConnectionMethod.OAuth)
|
||||
});
|
||||
|
||||
const clientSecretSchema = baseSchema.extend({
|
||||
method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret),
|
||||
credentials: z.object({
|
||||
clientSecret: z.string().trim().min(1, "Client Secret is required"),
|
||||
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required")
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => {
|
||||
const getDefaultValues = (appConnection?: TAzureAppConfigurationConnection): Partial<FormData> => {
|
||||
if (!appConnection) {
|
||||
return {
|
||||
app: AppConnection.AzureAppConfiguration,
|
||||
method: AzureAppConfigurationConnectionMethod.OAuth
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: appConnection.name,
|
||||
description: appConnection.description,
|
||||
app: appConnection.app,
|
||||
method: appConnection.method
|
||||
};
|
||||
const { credentials } = appConnection;
|
||||
|
||||
switch (appConnection.method) {
|
||||
case AzureAppConfigurationConnectionMethod.OAuth:
|
||||
if ("tenantId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureAppConfigurationConnectionMethod.OAuth,
|
||||
tenantId: credentials.tenantId
|
||||
};
|
||||
}
|
||||
break;
|
||||
case AzureAppConfigurationConnectionMethod.ClientSecret:
|
||||
if ("clientSecret" in credentials && "clientId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureAppConfigurationConnectionMethod.ClientSecret,
|
||||
credentials: {
|
||||
clientSecret: credentials.clientSecret,
|
||||
clientId: credentials.clientId,
|
||||
tenantId: credentials.tenantId
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
export const AzureAppConfigurationConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
@@ -43,41 +107,36 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection
|
||||
? {
|
||||
...appConnection,
|
||||
tenantId: appConnection.credentials.tenantId
|
||||
}
|
||||
: {
|
||||
app: AppConnection.AzureAppConfiguration,
|
||||
method: AzureAppConfigurationConnectionMethod.OAuth
|
||||
}
|
||||
defaultValues: getDefaultValues(appConnection)
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
setIsRedirecting(true);
|
||||
const onSubmitHandler = async (formData: FormData) => {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
localStorage.setItem(
|
||||
"azureAppConfigurationConnectionFormData",
|
||||
JSON.stringify({ ...formData, connectionId: appConnection?.id })
|
||||
);
|
||||
|
||||
switch (formData.method) {
|
||||
case AzureAppConfigurationConnectionMethod.OAuth:
|
||||
setIsRedirecting(true);
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
localStorage.setItem(
|
||||
"azureAppConfigurationConnectionFormData",
|
||||
JSON.stringify({ ...formData, connectionId: appConnection?.id })
|
||||
);
|
||||
window.location.assign(
|
||||
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-app-configuration`
|
||||
);
|
||||
break;
|
||||
case AzureAppConfigurationConnectionMethod.ClientSecret:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
}
|
||||
@@ -89,6 +148,9 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
|
||||
case AzureAppConfigurationConnectionMethod.OAuth:
|
||||
isMissingConfig = !oauthClientId;
|
||||
break;
|
||||
case AzureAppConfigurationConnectionMethod.ClientSecret:
|
||||
isMissingConfig = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
|
||||
}
|
||||
@@ -97,25 +159,8 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(onSubmitHandler)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Tenant ID"
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
@@ -155,6 +200,65 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Tenant ID"
|
||||
isOptional={selectedMethod === AzureAppConfigurationConnectionMethod.OAuth}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
setValue("credentials.tenantId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Client Secret-specific fields */}
|
||||
{selectedMethod === AzureAppConfigurationConnectionMethod.ClientSecret && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.clientId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.clientSecret"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client Secret"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+7
-3
@@ -121,7 +121,7 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
|
||||
const onSubmitHandler = (formData: FormData) => {
|
||||
const onSubmitHandler = async (formData: FormData) => {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
switch (formData.method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
@@ -137,7 +137,7 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
|
||||
break;
|
||||
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
onSubmit(formData);
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
@@ -240,7 +240,11 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
|
||||
label="Client Secret"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" placeholder="Enter your Client Secret" />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+100
-16
@@ -21,13 +21,6 @@ import {
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type AccessTokenForm = z.infer<typeof accessTokenSchema>;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureDevOpsConnection;
|
||||
onSubmit: (formData: AccessTokenForm) => Promise<void>;
|
||||
};
|
||||
|
||||
// Base schema with common fields
|
||||
const baseSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.AzureDevOps),
|
||||
@@ -49,10 +42,30 @@ const accessTokenSchema = baseSchema.extend({
|
||||
})
|
||||
});
|
||||
|
||||
const clientSecretSchema = baseSchema.extend({
|
||||
method: z.literal(AzureDevOpsConnectionMethod.ClientSecret),
|
||||
credentials: z.object({
|
||||
clientSecret: z.string().trim().min(1, "Client Secret is required"),
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required"),
|
||||
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||
orgName: z.string().trim().min(1, "Organization name is required")
|
||||
})
|
||||
});
|
||||
|
||||
// Union schema
|
||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, accessTokenSchema]);
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
oauthSchema,
|
||||
accessTokenSchema,
|
||||
clientSecretSchema
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
type OnSubmitForm = z.infer<typeof accessTokenSchema> | z.infer<typeof clientSecretSchema>;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureDevOpsConnection;
|
||||
onSubmit: (formData: OnSubmitForm) => Promise<void>;
|
||||
};
|
||||
|
||||
const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormData> => {
|
||||
if (!appConnection) {
|
||||
@@ -93,6 +106,25 @@ const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormD
|
||||
};
|
||||
}
|
||||
break;
|
||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||
if (
|
||||
"clientSecret" in credentials &&
|
||||
"tenantId" in credentials &&
|
||||
"clientId" in credentials &&
|
||||
"orgName" in credentials
|
||||
) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureDevOpsConnectionMethod.ClientSecret,
|
||||
credentials: {
|
||||
clientSecret: credentials.clientSecret,
|
||||
tenantId: credentials.tenantId,
|
||||
clientId: credentials.clientId,
|
||||
orgName: credentials.orgName
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
@@ -118,7 +150,8 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting, isDirty }
|
||||
formState: { isSubmitting, isDirty },
|
||||
setValue
|
||||
} = form;
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
@@ -138,11 +171,12 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
|
||||
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-devops`
|
||||
);
|
||||
break;
|
||||
|
||||
case AzureDevOpsConnectionMethod.AccessToken:
|
||||
onSubmit(formData);
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
}
|
||||
@@ -196,8 +230,8 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* OAuth-specific fields */}
|
||||
{selectedMethod === AzureDevOpsConnectionMethod.OAuth && (
|
||||
{(selectedMethod === AzureDevOpsConnectionMethod.OAuth ||
|
||||
selectedMethod === AzureDevOpsConnectionMethod.ClientSecret) && (
|
||||
<>
|
||||
<Controller
|
||||
name="tenantId"
|
||||
@@ -209,7 +243,14 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
|
||||
label="Tenant ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
setValue("credentials.tenantId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -223,7 +264,50 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
|
||||
label="Organization Name"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="myorganization" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="myorganization"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
setValue("credentials.orgName", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Client Secret-specific fields */}
|
||||
{selectedMethod === AzureDevOpsConnectionMethod.ClientSecret && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.clientId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.clientSecret"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client Secret"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+144
-37
@@ -20,19 +20,83 @@ import {
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureKeyVaultConnection;
|
||||
onSubmit: (formData: ClientSecretForm) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAppConnectionFieldsSchema.extend({
|
||||
const baseSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.AzureKeyVault),
|
||||
method: z.nativeEnum(AzureKeyVaultConnectionMethod),
|
||||
tenantId: z.string().trim().optional()
|
||||
method: z.nativeEnum(AzureKeyVaultConnectionMethod)
|
||||
});
|
||||
|
||||
const oauthSchema = baseSchema.extend({
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required"),
|
||||
method: z.literal(AzureKeyVaultConnectionMethod.OAuth)
|
||||
});
|
||||
|
||||
const clientSecretSchema = baseSchema.extend({
|
||||
method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret),
|
||||
credentials: z.object({
|
||||
clientSecret: z.string().trim().min(1, "Client Secret is required"),
|
||||
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required")
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
|
||||
const getDefaultValues = (appConnection?: TAzureKeyVaultConnection): Partial<FormData> => {
|
||||
if (!appConnection) {
|
||||
return {
|
||||
app: AppConnection.AzureKeyVault,
|
||||
method: AzureKeyVaultConnectionMethod.OAuth
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: appConnection.name,
|
||||
description: appConnection.description,
|
||||
app: appConnection.app,
|
||||
method: appConnection.method
|
||||
};
|
||||
const { credentials } = appConnection;
|
||||
|
||||
switch (appConnection.method) {
|
||||
case AzureKeyVaultConnectionMethod.OAuth:
|
||||
if ("tenantId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureKeyVaultConnectionMethod.OAuth,
|
||||
tenantId: credentials.tenantId
|
||||
};
|
||||
}
|
||||
break;
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
if ("clientSecret" in credentials && "clientId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureKeyVaultConnectionMethod.ClientSecret,
|
||||
credentials: {
|
||||
clientSecret: credentials.clientSecret,
|
||||
clientId: credentials.clientId,
|
||||
tenantId: credentials.tenantId
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
export const AzureKeyVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
@@ -43,41 +107,37 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection
|
||||
? {
|
||||
...appConnection,
|
||||
tenantId: appConnection.credentials.tenantId
|
||||
}
|
||||
: {
|
||||
app: AppConnection.AzureKeyVault,
|
||||
method: AzureKeyVaultConnectionMethod.OAuth
|
||||
}
|
||||
defaultValues: getDefaultValues(appConnection)
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
setIsRedirecting(true);
|
||||
const onSubmitHandler = async (formData: FormData) => {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
localStorage.setItem(
|
||||
"azureKeyVaultConnectionFormData",
|
||||
JSON.stringify({ ...formData, connectionId: appConnection?.id })
|
||||
);
|
||||
|
||||
switch (formData.method) {
|
||||
case AzureKeyVaultConnectionMethod.OAuth:
|
||||
setIsRedirecting(true);
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
localStorage.setItem(
|
||||
"azureKeyVaultConnectionFormData",
|
||||
JSON.stringify({ ...formData, connectionId: appConnection?.id })
|
||||
);
|
||||
window.location.assign(
|
||||
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://vault.azure.net/.default%20openid%20offline_access&state=${state}<:>azure-key-vault`
|
||||
);
|
||||
break;
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
}
|
||||
@@ -89,6 +149,9 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
|
||||
case AzureKeyVaultConnectionMethod.OAuth:
|
||||
isMissingConfig = !oauthClientId;
|
||||
break;
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
isMissingConfig = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
|
||||
}
|
||||
@@ -97,25 +160,9 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(onSubmitHandler)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Tenant ID"
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
@@ -155,6 +202,66 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
|
||||
isError={Boolean(error?.message)}
|
||||
label="Tenant ID"
|
||||
isOptional={selectedMethod === AzureKeyVaultConnectionMethod.OAuth}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
setValue("credentials.tenantId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Client Secret-specific fields */}
|
||||
{selectedMethod === AzureKeyVaultConnectionMethod.ClientSecret && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.clientId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.clientSecret"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client Secret"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+114
-5
@@ -3,11 +3,31 @@ import crypto from "crypto";
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { gatewaysQueryKeys } from "@app/hooks/api";
|
||||
import {
|
||||
GitHubConnectionMethod,
|
||||
TGitHubConnection,
|
||||
@@ -26,7 +46,12 @@ type Props = {
|
||||
|
||||
const formSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.GitHub),
|
||||
method: z.nativeEnum(GitHubConnectionMethod)
|
||||
method: z.nativeEnum(GitHubConnectionMethod),
|
||||
credentials: z
|
||||
.object({
|
||||
host: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -44,7 +69,8 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.GitHub,
|
||||
method: GitHubConnectionMethod.App
|
||||
method: GitHubConnectionMethod.App,
|
||||
gatewayId: null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,6 +81,9 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
|
||||
const onSubmit = (formData: FormData) => {
|
||||
@@ -66,15 +95,20 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
|
||||
JSON.stringify({ ...formData, connectionId: appConnection?.id })
|
||||
);
|
||||
|
||||
const githubHost =
|
||||
formData.credentials?.host && formData.credentials.host.length > 0
|
||||
? `https://${formData.credentials.host}`
|
||||
: "https://github.com";
|
||||
|
||||
switch (formData.method) {
|
||||
case GitHubConnectionMethod.App:
|
||||
window.location.assign(
|
||||
`https://github.com/apps/${appClientSlug}/installations/new?state=${state}`
|
||||
`${githubHost}/apps/${appClientSlug}/installations/new?state=${state}`
|
||||
);
|
||||
break;
|
||||
case GitHubConnectionMethod.OAuth:
|
||||
window.location.assign(
|
||||
`https://github.com/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/organization/app-connections/github/oauth/callback&state=${state}`
|
||||
`${githubHost}/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/organization/app-connections/github/oauth/callback&state=${state}`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
@@ -141,6 +175,81 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{subscription.gateway && (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="enterprise-options" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
<div className="order-1 ml-3">GitHub Enterprise Options</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent childrenClassName="px-0">
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<Controller
|
||||
name="credentials.host"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Hostname"
|
||||
isOptional
|
||||
>
|
||||
<Input {...field} placeholder="github.com" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+50
-46
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
@@ -78,6 +79,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
@@ -99,55 +101,57 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
{subscription.gateway && (
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
||||
+50
-46
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
@@ -75,6 +76,7 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
@@ -96,55 +98,57 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
{subscription.gateway && (
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
||||
+50
-46
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
@@ -75,6 +76,7 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
@@ -96,55 +98,57 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
{subscription.gateway && (
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
||||
+50
-46
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
@@ -75,6 +76,7 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
@@ -96,55 +98,57 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
}}
|
||||
>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
{subscription.gateway && (
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
||||
+19
-10
@@ -30,7 +30,8 @@ type BaseFormData = {
|
||||
isUpdate?: boolean;
|
||||
};
|
||||
|
||||
type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" | "description">;
|
||||
type GithubFormData = BaseFormData &
|
||||
Pick<TGitHubConnection, "name" | "method" | "description" | "gatewayId" | "credentials">;
|
||||
|
||||
type GithubRadarFormData = BaseFormData &
|
||||
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
||||
@@ -395,7 +396,7 @@ export const OAuthCallbackPage = () => {
|
||||
|
||||
clearState(AppConnection.GitHub);
|
||||
|
||||
const { connectionId, name, description, returnUrl } = formData;
|
||||
const { connectionId, name, description, returnUrl, gatewayId, credentials } = formData;
|
||||
|
||||
try {
|
||||
if (connectionId) {
|
||||
@@ -406,14 +407,18 @@ export const OAuthCallbackPage = () => {
|
||||
connectionId,
|
||||
credentials: {
|
||||
code: code as string,
|
||||
installationId: installationId as string
|
||||
}
|
||||
installationId: installationId as string,
|
||||
...(credentials?.host && { host: credentials.host })
|
||||
},
|
||||
gatewayId
|
||||
}
|
||||
: {
|
||||
connectionId,
|
||||
credentials: {
|
||||
code: code as string
|
||||
}
|
||||
code: code as string,
|
||||
...(credentials?.host && { host: credentials.host })
|
||||
},
|
||||
gatewayId
|
||||
})
|
||||
});
|
||||
} else {
|
||||
@@ -426,14 +431,18 @@ export const OAuthCallbackPage = () => {
|
||||
method: GitHubConnectionMethod.App,
|
||||
credentials: {
|
||||
code: code as string,
|
||||
installationId: installationId as string
|
||||
}
|
||||
installationId: installationId as string,
|
||||
...(credentials?.host && { host: credentials.host })
|
||||
},
|
||||
gatewayId
|
||||
}
|
||||
: {
|
||||
method: GitHubConnectionMethod.OAuth,
|
||||
credentials: {
|
||||
code: code as string
|
||||
}
|
||||
code: code as string,
|
||||
...(credentials?.host && { host: credentials.host })
|
||||
},
|
||||
gatewayId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
+7
@@ -32,6 +32,13 @@ export const PmtMethodsTable = () => {
|
||||
|
||||
const handleDeletePmtMethodBtnClick = async () => {
|
||||
if (!currentOrg?.id || !pmtMethodToRemove) return;
|
||||
if (data?.length === 1) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "You must have at least one payment method"
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteOrgPmtMethod.mutateAsync({
|
||||
organizationId: currentOrg.id,
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { NewProjectModal } from "@app/components/projects";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AllProjectView } from "./components/AllProjectView";
|
||||
@@ -38,8 +35,6 @@ export const ProjectsPage = () => {
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const { data: serverDetails, isLoading } = useFetchServerStatus();
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||
@@ -52,30 +47,6 @@ export const ProjectsPage = () => {
|
||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
|
||||
{!isLoading && !serverDetails?.redisConfigured && (
|
||||
<div className="mb-4 flex flex-col items-start justify-start text-3xl">
|
||||
<p className="mb-4 mr-4 font-semibold text-white">Announcements</p>
|
||||
<div className="flex w-full items-center rounded-md border border-blue-400/70 bg-blue-900/70 p-2 text-base text-mineshaft-100">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationCircle}
|
||||
className="mr-4 p-4 text-2xl text-mineshaft-50"
|
||||
/>
|
||||
Attention: Updated versions of Infisical now require Redis for full functionality. Learn
|
||||
how to configure it
|
||||
<a
|
||||
href="https://infisical.com/docs/self-hosting/configuration/redis"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="cursor-pointer pl-1 text-white underline underline-offset-2 duration-100 hover:text-blue-200 hover:decoration-blue-400">
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex flex-col items-start justify-start">
|
||||
<PageHeader
|
||||
title={
|
||||
|
||||
+2
-3
@@ -59,7 +59,6 @@ import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { useGetReminder } from "@app/hooks/api/reminders";
|
||||
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
@@ -269,9 +268,9 @@ export const SecretDetailSidebar = ({
|
||||
) => {
|
||||
switch (actorType) {
|
||||
case ActorType.USER:
|
||||
return `/${ProjectType.SecretManager}/${currentWorkspace.id}/members/${membershipId}`;
|
||||
return `/projects/secret-management/${currentWorkspace.id}/members/${membershipId}`;
|
||||
case ActorType.IDENTITY:
|
||||
return `/${ProjectType.SecretManager}/${currentWorkspace.id}/identities/${actorId}`;
|
||||
return `/projects/secret-management/${currentWorkspace.id}/identities/${actorId}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+6
@@ -1,12 +1,17 @@
|
||||
import { useServerConfig } from "@app/context";
|
||||
|
||||
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
|
||||
import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection";
|
||||
import { EnvironmentSection } from "../EnvironmentSection";
|
||||
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
||||
import { SecretDetectionIgnoreValuesSection } from "../SecretDetectionIgnoreValuesSection/SecretDetectionIgnoreValuesSection";
|
||||
import { SecretSharingSection } from "../SecretSharingSection";
|
||||
import { SecretSnapshotsLegacySection } from "../SecretSnapshotsLegacySection";
|
||||
import { SecretTagsSection } from "../SecretTagsSection";
|
||||
|
||||
export const SecretSettingsTab = () => {
|
||||
const { config } = useServerConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EnvironmentSection />
|
||||
@@ -15,6 +20,7 @@ export const SecretSettingsTab = () => {
|
||||
<SecretSharingSection />
|
||||
<SecretSnapshotsLegacySection />
|
||||
<PointInTimeVersionLimitSection />
|
||||
{config.paramsFolderSecretDetectionEnabled && <SecretDetectionIgnoreValuesSection />}
|
||||
<BackfillSecretReferenceSecretion />
|
||||
</div>
|
||||
);
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
|
||||
import { useProjectPermission, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
ignoreValues: z
|
||||
.object({
|
||||
value: z.string().trim().min(1, "Secret value is required")
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export const SecretDetectionIgnoreValuesSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { membership } = useProjectPermission();
|
||||
const { mutateAsync: updateProject } = useUpdateProject();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting, isDirty },
|
||||
handleSubmit,
|
||||
reset
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
ignoreValues: []
|
||||
}
|
||||
});
|
||||
|
||||
const ignoreValuesFormFields = useFieldArray({
|
||||
control,
|
||||
name: "ignoreValues"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const existingIgnoreValues = currentWorkspace?.secretDetectionIgnoreValues || [];
|
||||
reset({
|
||||
ignoreValues:
|
||||
existingIgnoreValues.length > 0
|
||||
? existingIgnoreValues.map((value) => ({ value }))
|
||||
: [{ value: "" }] // Show one empty field by default
|
||||
});
|
||||
}, [currentWorkspace?.secretDetectionIgnoreValues, reset]);
|
||||
|
||||
const handleIgnoreValuesSubmit = async ({ ignoreValues }: TForm) => {
|
||||
try {
|
||||
await updateProject({
|
||||
projectID: currentWorkspace.id,
|
||||
secretDetectionIgnoreValues: ignoreValues.map((item) => item.value)
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated secret detection ignore values",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed updating secret detection ignore values",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
|
||||
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-xl font-semibold">Secret Detection</p>
|
||||
</div>
|
||||
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">Define secret values to ignore when scanning designated parameter folders. Add values here to prevent false positives or allow approved sensitive data. These ignored values will not trigger policy violation alerts.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(handleIgnoreValuesSubmit)} autoComplete="off">
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{ignoreValuesFormFields.fields.map(({ id: ignoreValueFieldId }, i) => (
|
||||
<div key={ignoreValueFieldId} className="flex items-end space-x-2">
|
||||
<div className="flex-grow">
|
||||
{i === 0 && <span className="text-xs text-mineshaft-400">Secret Value</span>}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`ignoreValues.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Input {...field} placeholder="sk-1234567890abcdef" isDisabled={!isAdmin} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
ariaLabel="delete ignore value"
|
||||
className="bottom-0.5 h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => ignoreValuesFormFields.remove(i)}
|
||||
isDisabled={!isAdmin}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => ignoreValuesFormFields.append({ value: "" })}
|
||||
isDisabled={!isAdmin}
|
||||
>
|
||||
Add value to ignore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!isAdmin || !isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user