mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 06:45:28 +00:00
Merge remote-tracking branch 'origin/main' into feat-secret-input-autocomplete
This commit is contained in:
@@ -60,7 +60,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
|
||||
break;
|
||||
case "github":
|
||||
link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`;
|
||||
link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`;
|
||||
break;
|
||||
case "gitlab":
|
||||
link = `${window.location.origin}/integrations/gitlab/authorize`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
@@ -31,7 +31,7 @@ type Props = {
|
||||
export const IntegrationsPage = withProjectPermission(
|
||||
({ frameworkIntegrations }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
+25
-4
@@ -1,10 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types";
|
||||
|
||||
@@ -31,18 +38,32 @@ export const CloudIntegrationSection = ({
|
||||
"deleteConfirmation"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const isEmpty = !isLoading && !cloudIntegrations?.length;
|
||||
|
||||
const sortedCloudIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortedCloudIntegrations = useMemo(() => {
|
||||
const sortedIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (currentWorkspace?.environments.length === 0) {
|
||||
return sortedIntegrations.map((integration) => ({ ...integration, isAvailable: false }));
|
||||
}
|
||||
|
||||
return sortedIntegrations;
|
||||
}, [cloudIntegrations, currentWorkspace?.environments]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5">
|
||||
{currentWorkspace?.environments.length === 0 && (
|
||||
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
|
||||
+57
-48
@@ -9,11 +9,8 @@ import {
|
||||
AlertDescription,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
@@ -22,7 +19,7 @@ import { usePopUp } from "@app/hooks";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
type Props = {
|
||||
environments: Array<{ name: string; slug: string }>;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
integrations?: TIntegration[];
|
||||
isLoading?: boolean;
|
||||
onIntegrationDelete: (integration: TIntegration, cb: () => void) => void;
|
||||
@@ -77,32 +74,18 @@ export const IntegrationsSection = ({
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && isBotActive && (
|
||||
<div className="flex flex-col space-y-4 p-6 pt-0">
|
||||
<div className="flex flex-col min-w-max space-y-4 p-6 pt-0">
|
||||
{integrations?.map((integration) => (
|
||||
<div
|
||||
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 pb-0"
|
||||
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
|
||||
key={`integration-${integration?.id.toString()}`}
|
||||
>
|
||||
<div className="flex">
|
||||
<div>
|
||||
<FormControl label="Environment">
|
||||
<Select
|
||||
value={integration.environment.slug}
|
||||
isDisabled={integration.isActive}
|
||||
className="min-w-[8rem] border border-mineshaft-700"
|
||||
>
|
||||
{environments.map((environment) => {
|
||||
return (
|
||||
<SelectItem
|
||||
value={environment.slug}
|
||||
key={`environment-${environment.slug}`}
|
||||
>
|
||||
{environment.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{environments.find((e) => e.id === integration.envId)?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Secret Path" />
|
||||
@@ -142,11 +125,26 @@ export const IntegrationsSection = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label={integration?.metadata?.scope || "App"} />
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.integration === "hashicorp-vault"
|
||||
? `${integration.app} - path: ${integration.path}`
|
||||
: integration.app}
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(integration.integration === "aws-parameter-store" && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className="min-w-[8rem] max-w-[12rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(integration.integration === "aws-parameter-store" && `${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") &&
|
||||
`${integration.owner}/${integration.app}`) ||
|
||||
integration.app}
|
||||
</div>
|
||||
</div>
|
||||
{(integration.integration === "vercel" ||
|
||||
@@ -154,32 +152,39 @@ export const IntegrationsSection = ({
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
integration.integration === "bitbucket") && (
|
||||
integration.integration === "bitbucket" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Group" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment}
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Category" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" ||
|
||||
integration.integration === "github") && (
|
||||
<>
|
||||
{integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Group" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.metadata?.secretSuffix || "-"}
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.metadata?.secretSuffix || "-"}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex cursor-default items-center">
|
||||
@@ -214,7 +219,11 @@ export const IntegrationsSection = ({
|
||||
(popUp?.deleteConfirmation.data as TIntegration)?.integration || " "
|
||||
} integration for ${(popUp?.deleteConfirmation.data as TIntegration)?.app || " "}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)}
|
||||
deleteKey={(popUp?.deleteConfirmation?.data as TIntegration)?.app || ""}
|
||||
deleteKey={
|
||||
(popUp?.deleteConfirmation?.data as TIntegration)?.app ||
|
||||
(popUp?.deleteConfirmation?.data as TIntegration)?.owner ||
|
||||
""
|
||||
}
|
||||
onDeleteApproved={async () =>
|
||||
onIntegrationDelete(popUp?.deleteConfirmation.data as TIntegration, () =>
|
||||
handlePopUpClose("deleteConfirmation")
|
||||
|
||||
@@ -1,89 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios"
|
||||
|
||||
import { fetchUserDetails } from "@app/hooks/api/users/queries";
|
||||
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
|
||||
import { isLoggedIn } from "@app/reactQuery";
|
||||
|
||||
import {
|
||||
InitialStep,
|
||||
LDAPStep,
|
||||
MFAStep,
|
||||
SAMLSSOStep} from "./components";
|
||||
import { navigateUserToOrg } from "./Login.utils";
|
||||
import { InitialStep, LDAPStep, MFAStep, SAMLSSOStep } from "./components";
|
||||
import { navigateUserToSelectOrg } from "./Login.utils";
|
||||
|
||||
export const Login = () => {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
|
||||
useEffect(() => {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(akhilmhdh): workspace will be controlled by a workspace context
|
||||
const redirectToDashboard = async () => {
|
||||
const handleRedirects = async () => {
|
||||
try {
|
||||
// user details
|
||||
const userDetails = await fetchUserDetails()
|
||||
// send details back to client
|
||||
|
||||
if (queryParams && queryParams.get("callback_port")) {
|
||||
const callbackPort = queryParams.get("callback_port")
|
||||
|
||||
// send post request to cli with details
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl, { email: userDetails.email, privateKey: localStorage.getItem("PRIVATE_KEY"), JTWToken: getAuthToken() })
|
||||
const callbackPort = queryParams?.get("callback_port");
|
||||
// case: a callback port is set, meaning it's a cli login request: redirect to select org with callback port
|
||||
if (callbackPort) {
|
||||
navigateUserToSelectOrg(router, callbackPort);
|
||||
} else {
|
||||
// case: no callback port, meaning it's a regular login request: redirect to select org
|
||||
navigateUserToSelectOrg(router);
|
||||
}
|
||||
|
||||
await navigateUserToOrg(router);
|
||||
} catch (error) {
|
||||
console.log("Error - Not logged in yet");
|
||||
}
|
||||
};
|
||||
if (isLoggedIn()) {
|
||||
redirectToDashboard();
|
||||
handleRedirects();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<InitialStep
|
||||
setStep={setStep}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<MFAStep
|
||||
email={email}
|
||||
password={password}
|
||||
providerAuthToken={undefined}
|
||||
callbackPort={queryParams.get("callback_port")}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<SAMLSSOStep setStep={setStep} />
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<LDAPStep setStep={setStep} />
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<InitialStep
|
||||
setStep={setStep}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<MFAStep
|
||||
email={email}
|
||||
password={password}
|
||||
providerAuthToken={undefined}
|
||||
callbackPort={queryParams.get("callback_port")}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return <SAMLSSOStep setStep={setStep} />;
|
||||
case 3:
|
||||
return <LDAPStep setStep={setStep} />;
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return <div>{renderView()}</div>;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NextRouter } from "next/router";
|
||||
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { userKeys } from "@app/hooks/api/users/queries";
|
||||
import { queryClient } from "@app/reactQuery";
|
||||
|
||||
export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
|
||||
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
|
||||
|
||||
if (organizationId) {
|
||||
@@ -24,3 +26,15 @@ export const navigateUserToOrg = async (router: NextRouter, organizationId?: str
|
||||
router.push("/org/none");
|
||||
}
|
||||
};
|
||||
|
||||
export const navigateUserToSelectOrg = (router: NextRouter, cliCallbackPort?: string) => {
|
||||
queryClient.invalidateQueries(userKeys.getUser);
|
||||
|
||||
let redirectTo = "/login/select-organization";
|
||||
|
||||
if (cliCallbackPort) {
|
||||
redirectTo += `?callback_port=${cliCallbackPort}`;
|
||||
}
|
||||
|
||||
router.push(redirectTo, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
@@ -1,62 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import jwt_decode from "jwt-decode";
|
||||
|
||||
import {
|
||||
MFAStep,
|
||||
PasswordStep
|
||||
} from "./components";
|
||||
import { MFAStep, PasswordStep } from "./components";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
}
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
};
|
||||
|
||||
export const LoginSSO = ({ providerAuthToken }: Props) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [password, setPassword] = useState("");
|
||||
const [step, setStep] = useState(0);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const {
|
||||
username,
|
||||
isUserCompleted
|
||||
} = jwt_decode(providerAuthToken) as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserCompleted) {
|
||||
setStep(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<div />
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<PasswordStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
email={username}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setStep={setStep}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<MFAStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
email={username}
|
||||
password={password}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
const { username, isUserCompleted } = jwt_decode(providerAuthToken) as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserCompleted) {
|
||||
setStep(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return <div />;
|
||||
case 1:
|
||||
return (
|
||||
<PasswordStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
email={username}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setStep={setStep}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<MFAStep providerAuthToken={providerAuthToken} email={username} password={password} />
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
};
|
||||
|
||||
return <div>{renderView()}</div>;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import axios from "axios";
|
||||
|
||||
import Error from "@app/components/basic/Error";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
|
||||
import { navigateUserToOrg } from "../../Login.utils";
|
||||
import { navigateUserToSelectOrg } from "../../Login.utils";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
@@ -26,13 +25,25 @@ type Props = {
|
||||
|
||||
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState(false);
|
||||
const { config } = useServerConfig();
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_SAML_ORG_SLUG && process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default") {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
window.close();
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -57,17 +68,14 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// case: login was successful
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse });
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
|
||||
// on success, router.push to cli Login Successful page
|
||||
navigateUserToSelectOrg(router, callbackPort!);
|
||||
} else {
|
||||
setLoginError(true);
|
||||
createNotification({
|
||||
text: "CLI login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLogin({
|
||||
@@ -85,7 +93,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateUserToOrg(router);
|
||||
navigateUserToSelectOrg(router);
|
||||
|
||||
// case: login does not require MFA step
|
||||
createNotification({
|
||||
@@ -243,12 +251,12 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
|
||||
<div className="mt-6 flex flex-row text-sm text-bunker-400">
|
||||
<Link href="/signup">
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
Don't have an acount yet? {t("login.create-account")}
|
||||
Don't have an account yet? {t("login.create-account")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
<div className="mt-4" />
|
||||
)}
|
||||
<div className="mt-2 flex flex-row text-sm text-bunker-400">
|
||||
<Link href="/verify-email">
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
export { InitialStep } from "./InitialStep";
|
||||
|
||||
@@ -1,137 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
}
|
||||
setStep: (step: number) => void;
|
||||
};
|
||||
|
||||
export const LDAPStep = ({
|
||||
setStep
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const [organizationSlug, setOrganizationSlug] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const { t } = useTranslation();
|
||||
export const LDAPStep = ({ setStep }: Props) => {
|
||||
|
||||
const [organizationSlug, setOrganizationSlug] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// const queryParams = new URLSearchParams(window.location.search);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmission = async (e:React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const { nextUrl } = await loginLDAPRedirect({
|
||||
organizationSlug,
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (!nextUrl) {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
// const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// redirects either to /login/sso or /signup/sso
|
||||
window.open(nextUrl);
|
||||
window.close();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: add callback port support
|
||||
|
||||
// const callbackPort = queryParams.get("callback_port");
|
||||
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
// window.close();
|
||||
const handleSubmission = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const { nextUrl } = await loginLDAPRedirect({
|
||||
organizationSlug,
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (!nextUrl) {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
// redirects either to /login/sso or /signup/sso
|
||||
window.open(nextUrl);
|
||||
window.close();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8">
|
||||
What's your LDAP Login?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
<div className="relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={organizationSlug}
|
||||
onChange={(e) => setOrganizationSlug(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your organization slug..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your LDAP username..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your LDAP password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="select:-webkit-autofill:focus h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 w-full mx-auto flex items-center justify-center min-w-[20rem] md:min-w-[22rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex flex-row items-center justify-center mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="text-bunker-300 text-sm hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
// TODO: add callback port support
|
||||
|
||||
// const callbackPort = queryParams.get("callback_port");
|
||||
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
// window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
What's your LDAP Login?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
<div className="relative mx-auto flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={organizationSlug}
|
||||
onChange={(e) => setOrganizationSlug(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your organization slug..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<div className="relative mx-auto mt-2 flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your LDAP username..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mx-auto mt-2 flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your LDAP password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="select:-webkit-autofill:focus h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-1/4 w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LDAPStep } from "./LDAPStep";
|
||||
export { LDAPStep } from "./LDAPStep";
|
||||
|
||||
@@ -2,20 +2,22 @@ import React, { useState } from "react";
|
||||
import ReactCodeInput from "react-code-input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios"
|
||||
import axios from "axios";
|
||||
import jwt_decode from "jwt-decode";
|
||||
|
||||
import Error from "@app/components/basic/Error"; // which to notification
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import attemptCliLoginMfa from "@app/components/utilities/attemptCliLoginMfa"
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLoginMfa from "@app/components/utilities/attemptCliLoginMfa";
|
||||
import attemptLoginMfa from "@app/components/utilities/attemptLoginMfa";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useUpdateUserAuthMethods } from "@app/hooks/api";
|
||||
import { useSendMfaToken } from "@app/hooks/api/auth";
|
||||
import { useSelectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { fetchUserDetails } from "@app/hooks/api/users/queries";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
|
||||
import { navigateUserToOrg } from "../../Login.utils";
|
||||
import { navigateUserToOrg, navigateUserToSelectOrg } from "../../Login.utils";
|
||||
|
||||
// The style for the verification code input
|
||||
const props = {
|
||||
@@ -42,7 +44,7 @@ type Props = {
|
||||
password: string;
|
||||
providerAuthToken?: string;
|
||||
callbackPort?: string | null;
|
||||
}
|
||||
};
|
||||
|
||||
interface VerifyMfaTokenError {
|
||||
response: {
|
||||
@@ -56,12 +58,8 @@ interface VerifyMfaTokenError {
|
||||
};
|
||||
}
|
||||
|
||||
export const MFAStep = ({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
|
||||
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingResend, setIsLoadingResend] = useState(false);
|
||||
@@ -71,7 +69,8 @@ export const MFAStep = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sendMfaToken = useSendMfaToken();
|
||||
const { mutateAsync: updateUserAuthMethodsMutateAsync } = useUpdateUserAuthMethods();
|
||||
const { mutateAsync: updateUserAuthMethodsMutateAsync } = useUpdateUserAuthMethods();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
|
||||
const handleLoginMfa = async () => {
|
||||
try {
|
||||
@@ -79,16 +78,20 @@ export const MFAStep = ({
|
||||
let callbackPort: undefined | string;
|
||||
let authMethod: undefined | AuthMethod;
|
||||
let organizationId: undefined | string;
|
||||
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
callbackPort = queryParams.get("callback_port") || undefined;
|
||||
|
||||
if (providerAuthToken) {
|
||||
const decodedToken = jwt_decode(providerAuthToken) as any;
|
||||
|
||||
|
||||
isLinkingRequired = decodedToken.isLinkingRequired;
|
||||
callbackPort = decodedToken.callbackPort;
|
||||
authMethod = decodedToken.authMethod;
|
||||
organizationId = decodedToken?.organizationId;
|
||||
}
|
||||
|
||||
|
||||
if (mfaCode.length !== 6) {
|
||||
createNotification({
|
||||
text: "Please enter a 6-digit MFA code and try again",
|
||||
@@ -99,25 +102,44 @@ export const MFAStep = ({
|
||||
|
||||
setIsLoading(true);
|
||||
if (callbackPort) {
|
||||
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLoginMfa({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
mfaToken: mfaCode
|
||||
})
|
||||
});
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success){
|
||||
// case: login was successful
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl,{...isCliLoginSuccessful.loginResponse,email})
|
||||
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
|
||||
if (organizationId) {
|
||||
const { token: newJwtToken } = await selectOrganization({ organizationId });
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, {
|
||||
...isCliLoginSuccessful.loginResponse,
|
||||
JTWToken: newJwtToken
|
||||
});
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateUserToSelectOrg(router, callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
// cli login will fail in this case
|
||||
else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLoginMfa({
|
||||
@@ -126,25 +148,29 @@ export const MFAStep = ({
|
||||
providerAuthToken,
|
||||
mfaToken: mfaCode
|
||||
});
|
||||
|
||||
|
||||
if (isLoginSuccessful) {
|
||||
setIsLoading(false);
|
||||
|
||||
// case: login does not require MFA step
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (isLinkingRequired && authMethod) {
|
||||
const user = await fetchUserDetails();
|
||||
const newAuthMethods = [...user.authMethods, authMethod]
|
||||
const newAuthMethods = [...user.authMethods, authMethod];
|
||||
await updateUserAuthMethodsMutateAsync({
|
||||
authMethods: newAuthMethods
|
||||
authMethods: newAuthMethods
|
||||
});
|
||||
}
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
|
||||
if (organizationId) {
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
} else {
|
||||
navigateUserToSelectOrg(router);
|
||||
}
|
||||
} else {
|
||||
createNotification({
|
||||
text: "Failed to log in",
|
||||
@@ -152,7 +178,6 @@ export const MFAStep = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const error = err as VerifyMfaTokenError;
|
||||
createNotification({
|
||||
@@ -184,11 +209,11 @@ export const MFAStep = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mx-auto w-max md:px-8 pb-4 pt-4 md:mb-16">
|
||||
return (
|
||||
<form className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
|
||||
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
|
||||
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email} </p>
|
||||
<div className="hidden md:block w-max min-w-[20rem] mx-auto">
|
||||
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
|
||||
<ReactCodeInput
|
||||
name=""
|
||||
inputMode="tel"
|
||||
@@ -199,7 +224,7 @@ export const MFAStep = ({
|
||||
className="mt-6 mb-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="block md:hidden w-max mt-4 mx-auto">
|
||||
<div className="mx-auto mt-4 block w-max md:hidden">
|
||||
<ReactCodeInput
|
||||
name=""
|
||||
inputMode="tel"
|
||||
@@ -213,25 +238,28 @@ export const MFAStep = ({
|
||||
{typeof triesLeft === "number" && (
|
||||
<Error text={`${t("mfa.step2-code-error")} ${triesLeft}`} />
|
||||
)}
|
||||
<div className="flex flex-col mt-6 items-center justify-center lg:w-[19%] w-1/4 min-w-[20rem] mt-2 max-w-xs md:max-w-md mx-auto text-sm text-center md:text-left">
|
||||
<div className="text-l py-1 text-lg w-full">
|
||||
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={() => handleLoginMfa()}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className='h-14'
|
||||
className="h-14"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={isLoading}
|
||||
> {String(t("mfa.verify"))} </Button>
|
||||
>
|
||||
{" "}
|
||||
{String(t("mfa.verify"))}{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center w-full max-h-24 max-w-md mx-auto pt-2">
|
||||
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
|
||||
<div className="flex flex-row items-baseline gap-1 text-sm">
|
||||
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
|
||||
<div className="mt-2 text-bunker-400 text-md flex flex-row">
|
||||
<div className="text-md mt-2 flex flex-row text-bunker-400">
|
||||
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
|
||||
<span className='hover:underline hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
{isLoadingResend
|
||||
? t("signup.step2-resend-progress")
|
||||
: t("signup.step2-resend-submit")}
|
||||
@@ -239,8 +267,8 @@ export const MFAStep = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-400 pb-2">{t("signup.step2-spam-alert")}</p>
|
||||
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { MFAStep } from "./MFAStep";
|
||||
export { MFAStep } from "./MFAStep";
|
||||
|
||||
@@ -1,188 +1,224 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router"
|
||||
import axios from "axios"
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import jwt_decode from "jwt-decode";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import { useUpdateUserAuthMethods } from "@app/hooks/api";
|
||||
import { useSelectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { fetchUserDetails } from "@app/hooks/api/users/queries";
|
||||
|
||||
import { navigateUserToOrg } from "../../Login.utils";
|
||||
import { navigateUserToOrg, navigateUserToSelectOrg } from "../../Login.utils";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
email: string;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
setStep: (step: number) => void;
|
||||
}
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
email: string;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
setStep: (step: number) => void;
|
||||
};
|
||||
|
||||
export const PasswordStep = ({
|
||||
providerAuthToken,
|
||||
email,
|
||||
password,
|
||||
setPassword,
|
||||
setStep,
|
||||
providerAuthToken,
|
||||
email,
|
||||
password,
|
||||
setPassword,
|
||||
setStep
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { mutateAsync } = useUpdateUserAuthMethods();
|
||||
|
||||
const {
|
||||
callbackPort,
|
||||
isLinkingRequired,
|
||||
authMethod,
|
||||
organizationId
|
||||
} = jwt_decode(providerAuthToken) as any;
|
||||
|
||||
const handleLogin = async (e:React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (callbackPort) {
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken
|
||||
})
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { mutateAsync } = useUpdateUserAuthMethods();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
const { callbackPort, isLinkingRequired, authMethod, organizationId } = jwt_decode(
|
||||
providerAuthToken
|
||||
) as any;
|
||||
|
||||
if (isCliLoginSuccessful.mfaEnabled) {
|
||||
// case: login requires MFA step
|
||||
setStep(2);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// case: login was successful
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse })
|
||||
if (callbackPort) {
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken
|
||||
});
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
|
||||
// on success, router.push to cli Login Successful page
|
||||
}
|
||||
} else {
|
||||
const loginAttempt = await attemptLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
});
|
||||
|
||||
if (loginAttempt && loginAttempt.success) {
|
||||
// case: login was successful
|
||||
|
||||
if (loginAttempt.mfaEnabled) {
|
||||
// TODO: deal with MFA
|
||||
// case: login requires MFA step
|
||||
setIsLoading(false);
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// case: login does not require MFA step
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (isLinkingRequired) {
|
||||
const user = await fetchUserDetails();
|
||||
const newAuthMethods = [...user.authMethods, authMethod]
|
||||
await mutateAsync({
|
||||
authMethods: newAuthMethods
|
||||
});
|
||||
}
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
if (isCliLoginSuccessful.mfaEnabled) {
|
||||
// case: login requires MFA step
|
||||
setStep(2);
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your master password and try again.",
|
||||
type: "error"
|
||||
return;
|
||||
}
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
|
||||
|
||||
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
|
||||
if (organizationId) {
|
||||
const { token: newJwtToken } = await selectOrganization({ organizationId });
|
||||
|
||||
console.log(
|
||||
"organization id was present. new JWT token to be used in CLI:",
|
||||
newJwtToken
|
||||
);
|
||||
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, {
|
||||
...isCliLoginSuccessful.loginResponse,
|
||||
JTWToken: newJwtToken
|
||||
});
|
||||
console.error(err);
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateUserToSelectOrg(router, callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
className="h-full mx-auto w-full max-w-md px-6 pt-8"
|
||||
} else {
|
||||
const loginAttempt = await attemptLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken
|
||||
});
|
||||
|
||||
if (loginAttempt && loginAttempt.success) {
|
||||
// case: login was successful
|
||||
|
||||
if (loginAttempt.mfaEnabled) {
|
||||
// TODO: deal with MFA
|
||||
// case: login requires MFA step
|
||||
setIsLoading(false);
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// case: login does not require MFA step
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (isLinkingRequired) {
|
||||
const user = await fetchUserDetails();
|
||||
const newAuthMethods = [...user.authMethods, authMethod];
|
||||
await mutateAsync({
|
||||
authMethods: newAuthMethods
|
||||
});
|
||||
}
|
||||
|
||||
// case: organization ID is present from the provider auth token -- navigate directly to the org
|
||||
if (organizationId) {
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
if (userOrgs.length > 0) {
|
||||
navigateUserToSelectOrg(router);
|
||||
} else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your master password and try again.",
|
||||
type: "error"
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin} className="mx-auto h-full w-full max-w-md px-6 pt-8">
|
||||
<div className="mb-8">
|
||||
<p className="mx-auto mb-4 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
{isLinkingRequired ? "Link your account" : "What's your Infisical password?"}
|
||||
</p>
|
||||
{isLinkingRequired && (
|
||||
<div className="mx-auto flex w-max flex-col items-center text-xs text-bunker-400">
|
||||
<span className="max-w-sm px-4 text-center duration-200">
|
||||
An existing account without this SSO authentication method enabled was found under the
|
||||
same email. Login with your password to link the account.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative mx-auto flex max-h-24 w-1/4 w-full min-w-[22rem] items-center justify-center rounded-lg md:max-h-28 lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-1/4 w-full min-w-[22rem] items-center justify-center rounded-md text-center lg:w-1/6">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
className="h-14"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<p className="mx-auto flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-4">
|
||||
{isLinkingRequired ? "Link your account" : "What's your Infisical password?"}
|
||||
</p>
|
||||
{isLinkingRequired && (
|
||||
<div className="text-bunker-400 text-xs flex flex-col items-center w-max mx-auto">
|
||||
<span className='duration-200 max-w-sm text-center px-4'>
|
||||
An existing account without this SSO authentication method enabled was found under the same email. Login with your password to link the account.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 w-full mx-auto flex items-center justify-center min-w-[22rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-bunker-400 text-xs flex flex-col items-center w-max mx-auto mt-4">
|
||||
<span className='duration-200 max-w-sm text-center px-4'>
|
||||
Infisical Master Password serves as a decryption mechanism so that even Google is not able to access your secrets.
|
||||
</span>
|
||||
<Link href="/verify-email">
|
||||
<span className='hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>{t("login.forgot-password")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/login");
|
||||
}}
|
||||
type="button"
|
||||
className="text-bunker-400 text-xs hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-max flex-col items-center text-xs text-bunker-400">
|
||||
<span className="max-w-sm px-4 text-center duration-200">
|
||||
Infisical Master Password serves as a decryption mechanism so that even Google is not able
|
||||
to access your secrets.
|
||||
</span>
|
||||
<Link href="/verify-email">
|
||||
<span className="mt-2 cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
{t("login.forgot-password")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/login");
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-xs text-bunker-400 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
|
||||
@@ -4,67 +4,69 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
}
|
||||
setStep: (step: number) => void;
|
||||
};
|
||||
|
||||
export const SAMLSSOStep = ({
|
||||
setStep
|
||||
}: Props) => {
|
||||
const [ssoIdentifier, setSSOIdentifier] = useState("");
|
||||
const { t } = useTranslation();
|
||||
export const SAMLSSOStep = ({ setStep }: Props) => {
|
||||
const [ssoIdentifier, setSSOIdentifier] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const handleSubmission = (e:React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
window.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8">
|
||||
What's your organization slug?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
<div className="relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={ssoIdentifier}
|
||||
onChange={(e) => setSSOIdentifier(e.target.value)}
|
||||
type="text"
|
||||
placeholder="acme-123"
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 w-full mx-auto flex items-center justify-center min-w-[20rem] md:min-w-[22rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
Continue with SAML
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex flex-row items-center justify-center mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="text-bunker-300 text-sm hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const handleSubmission = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/saml2/organizations/${ssoIdentifier}${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
What's your organization slug?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
<div className="relative mx-auto flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={ssoIdentifier}
|
||||
onChange={(e) => setSSOIdentifier(e.target.value)}
|
||||
type="text"
|
||||
placeholder="acme-123"
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-1/4 w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
Continue with SAML
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { SAMLSSOStep } from "./SAMLSSOStep";
|
||||
export { SAMLSSOStep } from "./SAMLSSOStep";
|
||||
|
||||
@@ -4,4 +4,4 @@ export { MFAStep } from "./MFAStep";
|
||||
export { SAMLSSOStep } from "./SAMLSSOStep";
|
||||
|
||||
// SSO-specific step
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
|
||||
@@ -3,10 +3,16 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
|
||||
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
|
||||
import {
|
||||
OrgGroupsTab,
|
||||
OrgIdentityTab,
|
||||
OrgMembersTab,
|
||||
OrgRoleTabSection
|
||||
} from "./components";
|
||||
|
||||
enum TabSections {
|
||||
Member = "members",
|
||||
Groups = "groups",
|
||||
Roles = "roles",
|
||||
Identities = "identities"
|
||||
}
|
||||
@@ -20,12 +26,10 @@ export const MembersPage = withPermission(
|
||||
<Tabs defaultValue={TabSections.Member}>
|
||||
<TabList>
|
||||
<Tab value={TabSections.Member}>People</Tab>
|
||||
<Tab value={TabSections.Groups}>Groups</Tab>
|
||||
<Tab value={TabSections.Identities}>
|
||||
<div className="flex items-center">
|
||||
<p>Machine Identities</p>
|
||||
<div className="ml-2 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
New
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value={TabSections.Roles}>Organization Roles</Tab>
|
||||
@@ -33,6 +37,9 @@ export const MembersPage = withPermission(
|
||||
<TabPanel value={TabSections.Member}>
|
||||
<OrgMembersTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Groups}>
|
||||
<OrgGroupsTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<OrgIdentityTab />
|
||||
</TabPanel>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { OrgGroupsSection } from "./components";
|
||||
|
||||
export const OrgGroupsTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-org-groups"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<OrgGroupsSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context";
|
||||
import {
|
||||
useAddUserToGroup,
|
||||
useListGroupUsers,
|
||||
useRemoveUserFromGroup} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["groupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrgGroupMembersModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
|
||||
const popUpData = popUp?.groupMembers?.data as {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const { data, isLoading } = useListGroupUsers({
|
||||
groupSlug: popUpData?.slug,
|
||||
offset: (page - 1) * perPage,
|
||||
limit: perPage,
|
||||
username: searchMemberFilter
|
||||
});
|
||||
|
||||
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
|
||||
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
|
||||
|
||||
const handleAssignment = async (username: string, assign: boolean) => {
|
||||
try {
|
||||
if (!popUpData?.slug) return;
|
||||
|
||||
if (assign) {
|
||||
await assignMutateAsync({
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
} else {
|
||||
await unassignMutateAsync({
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.groupMembers?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("groupMembers", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Manage Group Members">
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>User</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
|
||||
{!isLoading && data?.users?.map(({
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
isPartOfGroup
|
||||
}) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName} ${lastName}`}</p>
|
||||
<p>{username}</p>
|
||||
</Td>
|
||||
<Td className="flex justify-end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAssignment(username, !isPartOfGroup)}
|
||||
>
|
||||
{isPartOfGroup ? "Unassign" : "Assign"}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data?.totalCount !== undefined && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !data?.users?.length && (
|
||||
<EmptyState
|
||||
title="No users found"
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const GroupFormSchema = z.object({
|
||||
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
|
||||
slug: z.string().min(5, "Slug cannot be empty").max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.string()
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["group"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["group"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: roles } = useGetOrgRoles(currentOrg?.id || "");
|
||||
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup();
|
||||
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<TGroupFormData>({
|
||||
resolver: zodResolver(GroupFormSchema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const group = popUp?.group?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role: string;
|
||||
customRole: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!roles?.length) return;
|
||||
|
||||
if (group) {
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole?.slug ?? group.role
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: roles[0].slug
|
||||
});
|
||||
}
|
||||
}, [popUp?.group?.data, roles]);
|
||||
|
||||
const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const group = popUp?.group?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
if (group) {
|
||||
await updateMutateAsync({
|
||||
currentSlug: group.slug,
|
||||
name,
|
||||
slug,
|
||||
role: role || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("group", false);
|
||||
reset();
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.group?.data ? "updated" : "created"} group`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${popUp?.group?.data ? "updated" : "created"} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.group?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("group", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="Engineering" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="engineering" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`org-group-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={createIsLoading || updateIsLoading}
|
||||
>
|
||||
{!popUp?.group?.data ? "Create" : "Update"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("group")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { OrgGroupMembersModal } from "./OrgGroupMembersModal";
|
||||
import { OrgGroupModal } from "./OrgGroupModal";
|
||||
import { OrgGroupsTable } from "./OrgGroupsTable";
|
||||
|
||||
export const OrgGroupsSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"group",
|
||||
"groupMembers",
|
||||
"deleteGroup",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const handleAddGroupModal = () => {
|
||||
if (!subscription?.groups) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can manage users more efficiently with groups if you upgrade your Infisical plan."
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("group");
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteGroupSubmit = async ({
|
||||
name,
|
||||
slug
|
||||
}: {
|
||||
name: string;
|
||||
slug: string;
|
||||
}) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
slug
|
||||
});
|
||||
createNotification({
|
||||
text: `Successfully deleted the group named ${name}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to delete the group named ${name}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("deleteGroup");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Group
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<OrgGroupsTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
<OrgGroupModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<OrgGroupMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to delete the group named ${
|
||||
(popUp?.deleteGroup?.data as { name: string })?.name || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteGroupSubmit(
|
||||
(popUp?.deleteGroup?.data as { name: string; slug: string })
|
||||
)
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faPencil, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization} from "@app/context";
|
||||
import {
|
||||
useGetOrganizationGroups,
|
||||
useGetOrgRoles,
|
||||
useUpdateGroup
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["group", "deleteGroup", "groupMembers"]
|
||||
>,
|
||||
data?: {
|
||||
groupId?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
role?: string;
|
||||
customRole?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const OrgGroupsTable = ({
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { isLoading, data: groups } = useGetOrganizationGroups(orgId);
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
|
||||
const handleChangeRole = async ({
|
||||
currentSlug,
|
||||
role
|
||||
}: {
|
||||
currentSlug: string;
|
||||
role: string;
|
||||
}) => {
|
||||
try {
|
||||
await updateMutateAsync({
|
||||
currentSlug,
|
||||
role
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated group role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update group role",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={searchGroupsFilter}
|
||||
onChange={(e) => setSearchGroupsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th>Role</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
|
||||
{!isLoading && groups?.map(({ id, name, slug, role, customRole }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`org-group-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Select
|
||||
value={role === "custom" ? (customRole?.slug as string) : role}
|
||||
isDisabled={!isAllowed}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
handleChangeRole({
|
||||
currentSlug: slug,
|
||||
role: selectedRole
|
||||
})
|
||||
}
|
||||
>
|
||||
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
|
||||
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
|
||||
{roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Tooltip content="Manage group members">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("groupMembers", {
|
||||
slug
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUsers} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Tooltip content="Edit group">
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("group", {
|
||||
groupId: id,
|
||||
name,
|
||||
slug,
|
||||
role,
|
||||
customRole
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Tooltip content="Delete group">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
slug,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{groups?.length === 0 && (
|
||||
<EmptyState title="No groups found" icon={faUsers} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { OrgGroupsSection } from "./OrgGroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGroupsSection } from "./OrgGroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGroupsTab } from "./OrgGroupsTab";
|
||||
@@ -1,19 +1,17 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
IdentitySection
|
||||
} from "./components";
|
||||
import { IdentitySection } from "./components";
|
||||
|
||||
export const OrgIdentityTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<IdentitySection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<IdentitySection />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
+84
-87
@@ -1,14 +1,14 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
UpgradePlanModal
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { IdentityAuthMethod } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
@@ -16,93 +16,90 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["identityAuthMethod", "upgradePlan"]>;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identityAuthMethod", "upgradePlan"]>, state?: boolean) => void;
|
||||
}
|
||||
popUp: UsePopUpState<["identityAuthMethod", "upgradePlan"]>;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["identityAuthMethod", "upgradePlan"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
const identityAuthMethods = [
|
||||
{ label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }
|
||||
];
|
||||
const identityAuthMethods = [{ label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }];
|
||||
|
||||
const schema = yup.object({
|
||||
authMethod: yup.string().required("Auth method is required"), // TODO: better enforcement here
|
||||
}).required();
|
||||
const schema = yup
|
||||
.object({
|
||||
authMethod: yup.string().required("Auth method is required") // TODO: better enforcement here
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const IdentityAuthMethodModal = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
|
||||
const {
|
||||
control
|
||||
// watch,
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
// watch,
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
const identityAuthMethodData = popUp?.identityAuthMethod?.data as {
|
||||
identityId: string;
|
||||
name: string;
|
||||
authMethod?: IdentityAuthMethod;
|
||||
};
|
||||
|
||||
const identityAuthMethodData = popUp?.identityAuthMethod?.data as {
|
||||
identityId: string;
|
||||
name: string;
|
||||
authMethod?: IdentityAuthMethod;
|
||||
}
|
||||
|
||||
// const authMethod = watch("authMethod");
|
||||
|
||||
const renderIdentityAuthForm = () => {
|
||||
return (
|
||||
<IdentityUniversalAuthForm
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
identityAuthMethodData={identityAuthMethodData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// const authMethod = watch("authMethod");
|
||||
|
||||
const renderIdentityAuthForm = () => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.identityAuthMethod?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("identityAuthMethod", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${identityAuthMethodData?.authMethod ? "Update" : "Configure"} Identity Auth Method for ${identityAuthMethodData?.name ?? ""}`}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="authMethod"
|
||||
defaultValue={IdentityAuthMethod.UNIVERSAL_AUTH}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Auth Method"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{identityAuthMethods.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{renderIdentityAuthForm()}
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp?.upgradePlan?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<IdentityUniversalAuthForm
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
identityAuthMethodData={identityAuthMethodData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.identityAuthMethod?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("identityAuthMethod", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={`${
|
||||
identityAuthMethodData?.authMethod ? "Update" : "Configure"
|
||||
} Identity Auth Method for ${identityAuthMethodData?.name ?? ""}`}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="authMethod"
|
||||
defaultValue={IdentityAuthMethod.UNIVERSAL_AUTH}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Auth Method" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{identityAuthMethods.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{renderIdentityAuthForm()}
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp?.upgradePlan?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
+23
-12
@@ -3,7 +3,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import { IdentityAuthMethod } from "@app/hooks/api/identities";
|
||||
import { IdentityAuthMethod, useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = yup
|
||||
@@ -40,8 +40,8 @@ type Props = {
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle }: Props) => {
|
||||
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
@@ -50,6 +50,7 @@ export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Pro
|
||||
|
||||
const { mutateAsync: createMutateAsync } = useCreateIdentity();
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
|
||||
const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -112,21 +113,31 @@ export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Pro
|
||||
// create
|
||||
|
||||
const {
|
||||
id: createdId,
|
||||
name: createdName,
|
||||
authMethod
|
||||
id: createdId
|
||||
// name: createdName,
|
||||
// authMethod
|
||||
} = await createMutateAsync({
|
||||
name,
|
||||
role: role || undefined,
|
||||
organizationId: orgId
|
||||
});
|
||||
|
||||
handlePopUpToggle("identity", false);
|
||||
handlePopUpOpen("identityAuthMethod", {
|
||||
await addMutateAsync({
|
||||
organizationId: orgId,
|
||||
identityId: createdId,
|
||||
name: createdName,
|
||||
authMethod
|
||||
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
accessTokenTTL: 2592000,
|
||||
accessTokenMaxTTL: 2592000,
|
||||
accessTokenNumUsesLimit: 0
|
||||
});
|
||||
|
||||
handlePopUpToggle("identity", false);
|
||||
// handlePopUpOpen("identityAuthMethod", {
|
||||
// identityId: createdId,
|
||||
// name: createdName,
|
||||
// authMethod
|
||||
// });
|
||||
}
|
||||
|
||||
createNotification({
|
||||
@@ -140,7 +151,7 @@ export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Pro
|
||||
const error = err as any;
|
||||
const text =
|
||||
error?.response?.data?.message ??
|
||||
`Failed to ${popUp?.identity?.data ? "updated" : "created"} identity`;
|
||||
`Failed to ${popUp?.identity?.data ? "update" : "create"} identity`;
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
@@ -20,7 +20,7 @@ export const IdentitySection = withPermission(
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentity();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { faKey, faLock, faPencil, faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -44,7 +44,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { format } from "date-fns";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
@@ -66,7 +66,7 @@ export const IdentityUniversalAuthClientSecretModal = ({
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const [token, setToken] = useState("");
|
||||
const [isClientSecretCopied, setIsClientSecretCopied] = useToggle(false);
|
||||
const [isClientIdCopied, setIsClientIdCopied] = useToggle(false);
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import {
|
||||
@@ -63,7 +63,7 @@ export const IdentityUniversalAuthForm = ({
|
||||
handlePopUpToggle,
|
||||
identityAuthMethodData
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { OrgIdentityTab } from "./OrgIdentityTab";
|
||||
export { OrgIdentityTab } from "./OrgIdentityTab";
|
||||
|
||||
@@ -3,15 +3,15 @@ import { motion } from "framer-motion";
|
||||
import { OrgMembersSection } from "./components";
|
||||
|
||||
export const OrgMembersTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<OrgMembersSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-org-members"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<OrgMembersSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
@@ -30,7 +30,7 @@ export const AddOrgMemberModal = ({
|
||||
completeInviteLink,
|
||||
setCompleteInviteLink
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@@ -23,11 +23,11 @@ import { AddOrgMemberModal } from "./AddOrgMemberModal";
|
||||
import { OrgMembersTable } from "./OrgMembersTable";
|
||||
|
||||
export const OrgMembersSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id ?? "";
|
||||
|
||||
|
||||
const [completeInviteLink, setCompleteInviteLink] = useState<string>("");
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
|
||||
+6
-5
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@@ -49,7 +49,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { user } = useUser();
|
||||
@@ -208,7 +208,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{(status === "invited" || status === "verified") && email &&
|
||||
{(status === "invited" || status === "verified") &&
|
||||
email &&
|
||||
serverDetails?.emailConfigured && (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
@@ -240,7 +241,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
handlePopUpOpen("removeMember", { orgMembershipId, username });
|
||||
}}
|
||||
size="lg"
|
||||
@@ -263,7 +264,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && filterdUser?.length === 0 && (
|
||||
<EmptyState title="No project members found" icon={faUsers} />
|
||||
<EmptyState title="No organization members found" icon={faUsers} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { OrgMembersTab } from "./OrgMembersTab";
|
||||
export { OrgMembersTab } from "./OrgMembersTab";
|
||||
|
||||
+12
-5
@@ -7,13 +7,14 @@ import {
|
||||
faMoneyBill,
|
||||
faServer,
|
||||
faSignIn,
|
||||
faUser,
|
||||
faUserCog,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateOrgRole, useUpdateOrgRole } from "@app/hooks/api";
|
||||
@@ -35,11 +36,17 @@ type Props = {
|
||||
|
||||
const SIMPLE_PERMISSION_OPTIONS = [
|
||||
{
|
||||
title: "Members management",
|
||||
subtitle: "Invite, view and remove members from the organization",
|
||||
icon: faUsers,
|
||||
title: "User management",
|
||||
subtitle: "Invite, view and remove users from the organization",
|
||||
icon: faUser,
|
||||
formName: "member"
|
||||
},
|
||||
{
|
||||
title: "Group management",
|
||||
subtitle: "Invite, view and remove user groups from the organization",
|
||||
icon: faUsers,
|
||||
formName: "groups"
|
||||
},
|
||||
{
|
||||
title: "Machine identity management",
|
||||
subtitle: "Create, view, update and remove (machine) identities from the organization",
|
||||
@@ -100,7 +107,7 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
const isNonEditable = ["owner", "admin", "member", "no-access"].includes(role?.slug || "");
|
||||
const isNewRole = !role?.slug;
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const {
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const formSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
member: generalPermissionSchema,
|
||||
groups: generalPermissionSchema,
|
||||
role: generalPermissionSchema,
|
||||
settings: generalPermissionSchema,
|
||||
"service-account": generalPermissionSchema,
|
||||
|
||||
+5
-5
@@ -71,7 +71,7 @@ const getPermissionList = (option: Props["formName"]) => {
|
||||
case "incident-contact":
|
||||
return INCIDENT_CONTACTS_PERMISSIONS;
|
||||
case "member":
|
||||
return MEMBERS_PERMISSIONS
|
||||
return MEMBERS_PERMISSIONS;
|
||||
default:
|
||||
return PERMISSIONS;
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export const SimpleLevelPermissionOption = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-10 py-6 bg-mineshaft-800 rounded-md",
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
@@ -157,8 +157,8 @@ export const SimpleLevelPermissionOption = ({
|
||||
<div>
|
||||
<FontAwesomeIcon icon={icon} className="text-4xl" />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col">
|
||||
<div className="font-medium mb-1 text-lg">{title}</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg font-medium">{title}</div>
|
||||
<div className="text-xs font-light">{subtitle}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -178,7 +178,7 @@ export const SimpleLevelPermissionOption = ({
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
||||
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
|
||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
||||
>
|
||||
{isCustom &&
|
||||
getPermissionList(formName).map(({ action, label }) => (
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-10 py-6 bg-mineshaft-800 rounded-md",
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
@@ -84,8 +84,8 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faMoneyBill} className="text-4xl" />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col">
|
||||
<div className="font-medium mb-1 text-lg">Project</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg font-medium">Project</div>
|
||||
<div className="text-xs font-light">
|
||||
View and create new projects in this organization
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
||||
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
|
||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
||||
>
|
||||
{isCustom &&
|
||||
PERMISSIONS.map(({ action, label }) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@@ -31,7 +31,7 @@ export const OrgRoleTable = ({ onSelectRole }: Props) => {
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
|
||||
|
||||
const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId);
|
||||
@@ -49,7 +49,7 @@ export const OrgRoleTable = ({ onSelectRole }: Props) => {
|
||||
handlePopUpClose("deleteRole");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to create role" });
|
||||
createNotification({ type: "error", text: "Failed to delete role" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { OrgGroupsTab } from "./OrgGroupsTab";
|
||||
export { OrgIdentityTab } from "./OrgIdentityTab";
|
||||
export { OrgMembersTab } from "./OrgMembersTab";
|
||||
export { OrgRoleTabSection } from "./OrgRoleTabSection";
|
||||
export { OrgRoleTabSection } from "./OrgRoleTabSection";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CreateOrgModal } from "../components";
|
||||
|
||||
export const NonePage = () => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
handlePopUpToggle("createOrg", true);
|
||||
}, []);
|
||||
@@ -19,4 +19,4 @@ export const NonePage = () => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { NonePage } from "./NonePage";
|
||||
export { NonePage } from "./NonePage";
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useRouter } from "next/router";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import z from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useCreateOrg } from "@app/hooks/api";
|
||||
import { useCreateOrg, useSelectOrganization } from "@app/hooks/api";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -22,7 +22,7 @@ interface CreateOrgModalProps {
|
||||
}
|
||||
|
||||
export const CreateOrgModal: FC<CreateOrgModalProps> = ({ isOpen, onClose }) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
@@ -37,14 +37,21 @@ export const CreateOrgModal: FC<CreateOrgModalProps> = ({ isOpen, onClose }) =>
|
||||
}
|
||||
});
|
||||
|
||||
const { mutateAsync } = useCreateOrg();
|
||||
const { mutateAsync: createOrg } = useCreateOrg({
|
||||
invalidate: false
|
||||
});
|
||||
const { mutateAsync: selectOrg } = useSelectOrganization();
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
try {
|
||||
const organization = await mutateAsync({
|
||||
const organization = await createOrg({
|
||||
name
|
||||
});
|
||||
|
||||
await selectOrg({
|
||||
organizationId: organization.id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created organization",
|
||||
type: "success"
|
||||
|
||||
@@ -6,9 +6,9 @@ import { LogsSection } from "./components";
|
||||
export const AuditLogsPage = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full">
|
||||
<div className="max-w-7xl px-6 w-full">
|
||||
<div className="py-6 sticky top-0 z-10 bg-bunker-800">
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-7xl px-6">
|
||||
<div className="sticky top-0 z-10 bg-bunker-800 py-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@ export const LogsSection = () => {
|
||||
resolver: yupResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
page: 1,
|
||||
perPage: 10
|
||||
perPage: 10,
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - 1))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LogsSection } from "./LogsSection";
|
||||
export { LogsSection } from "./LogsSection";
|
||||
|
||||
@@ -2,25 +2,25 @@ import * as yup from "yup";
|
||||
|
||||
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
|
||||
export const auditLogFilterFormSchema = yup.object({
|
||||
eventType: yup.string()
|
||||
.oneOf(Object.values(EventType), "Invalid event type"),
|
||||
export const auditLogFilterFormSchema = yup
|
||||
.object({
|
||||
eventType: yup.string().oneOf(Object.values(EventType), "Invalid event type"),
|
||||
actor: yup.string(),
|
||||
userAgentType: yup.string()
|
||||
.oneOf(Object.values(UserAgentType), "Invalid user agent type"),
|
||||
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),
|
||||
startDate: yup.date(),
|
||||
endDate: yup.date().min(yup.ref("startDate"), "End date cannot be before start date"),
|
||||
page: yup.number(),
|
||||
perPage: yup.number()
|
||||
}).required();
|
||||
})
|
||||
.required();
|
||||
|
||||
export type AuditLogFilterFormData = yup.InferType<typeof auditLogFilterFormSchema>;
|
||||
|
||||
export type SetValueType = (
|
||||
name: keyof AuditLogFilterFormData,
|
||||
value: any,
|
||||
name: keyof AuditLogFilterFormData,
|
||||
value: any,
|
||||
options?: {
|
||||
shouldValidate?: boolean,
|
||||
shouldDirty?: boolean
|
||||
shouldValidate?: boolean;
|
||||
shouldDirty?: boolean;
|
||||
}
|
||||
) => void;
|
||||
) => void;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { AuditLogsPage } from "./AuditLogsPage";
|
||||
export { AuditLogsPage } from "./AuditLogsPage";
|
||||
|
||||
@@ -6,8 +6,8 @@ import { IPAllowlistSection } from "./components";
|
||||
export const IPAllowlistPage = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full">
|
||||
<div className="max-w-7xl px-6 w-full">
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-7xl px-6">
|
||||
<div className="my-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
|
||||
<div />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useAddTrustedIp, useGetMyIp, useUpdateTrustedIp } from "@app/hooks/api";
|
||||
@@ -25,7 +25,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IPAllowlistModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { data, isLoading } = useGetMyIp();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import { IPAllowlistModal } from "./IPAllowlistModal";
|
||||
import { IPAllowlistTable } from "./IPAllowlistTable";
|
||||
|
||||
export const IPAllowlistSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync } = useDeleteTrustedIp();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { IPAllowlistSection } from "./IPAllowlistSection";
|
||||
export { IPAllowlistSection } from "./IPAllowlistSection";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { IPAllowlistPage } from "./IPAllowlistPage";
|
||||
export { IPAllowlistPage } from "./IPAllowlistPage";
|
||||
|
||||
@@ -2,20 +2,28 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components";
|
||||
import {
|
||||
GroupsTab,
|
||||
IdentityTab,
|
||||
MemberListTab,
|
||||
ProjectRoleListTab,
|
||||
ServiceTokenTab
|
||||
} from "./components";
|
||||
|
||||
enum TabSections {
|
||||
Member = "members",
|
||||
Roles = "roles",
|
||||
Groups = "groups",
|
||||
Identities = "identities",
|
||||
ServiceTokens = "service-tokens"
|
||||
}
|
||||
|
||||
export const MembersPage = withProjectPermission(
|
||||
() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||
@@ -23,6 +31,9 @@ export const MembersPage = withProjectPermission(
|
||||
<Tabs defaultValue={TabSections.Member}>
|
||||
<TabList>
|
||||
<Tab value={TabSections.Member}>People</Tab>
|
||||
{currentWorkspace?.version && currentWorkspace.version > 1 && (
|
||||
<Tab value={TabSections.Groups}>Groups</Tab>
|
||||
)}
|
||||
<Tab value={TabSections.Identities}>
|
||||
<div className="flex items-center">
|
||||
<p>Machine Identities</p>
|
||||
@@ -32,16 +43,21 @@ export const MembersPage = withProjectPermission(
|
||||
<Tab value={TabSections.Roles}>Project Roles</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={TabSections.Member}>
|
||||
<motion.div
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<MemberListTab />
|
||||
</motion.div>
|
||||
<MemberListTab />
|
||||
</TabPanel>
|
||||
{currentWorkspace?.version && currentWorkspace.version > 1 && (
|
||||
<TabPanel value={TabSections.Groups}>
|
||||
<motion.div
|
||||
key="panel-groups"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<GroupsTab />
|
||||
</motion.div>
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<IdentityTab />
|
||||
</TabPanel>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
GroupsSection
|
||||
} from "./components";
|
||||
|
||||
export const GroupsTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-groups"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<GroupsSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem} from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddGroupToWorkspace,
|
||||
useGetOrganizationGroups,
|
||||
useGetProjectRoles,
|
||||
useListWorkspaceGroups,
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
slug: z.string(),
|
||||
role: z.string()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["group"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const GroupModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const orgId = currentOrg?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: groups } = useGetOrganizationGroups(orgId);
|
||||
const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || "");
|
||||
|
||||
const { data: roles } = useGetProjectRoles(workspaceId);
|
||||
|
||||
const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace();
|
||||
|
||||
const filteredGroupMembershipOrgs = useMemo(() => {
|
||||
const wsGroupIds = new Map();
|
||||
|
||||
groupMemberships?.forEach((groupMembership) => {
|
||||
wsGroupIds.set(groupMembership.group.id, true);
|
||||
});
|
||||
|
||||
return (groups || []).filter(({ id }) => !wsGroupIds.has(id));
|
||||
}, [groups, groupMemberships]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ slug, role }: FormData) => {
|
||||
try {
|
||||
await addGroupToWorkspaceMutateAsync({
|
||||
projectSlug: currentWorkspace?.slug || "",
|
||||
groupSlug: slug,
|
||||
role: role || undefined
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpToggle("group", false);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added group to project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: "Failed to add group to project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.group?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("group", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add Group to Project">
|
||||
{filteredGroupMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
defaultValue={filteredGroupMembershipOrgs?.[0]?.id}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{filteredGroupMembershipOrgs.map(({ name, slug, id }) => (
|
||||
<SelectItem value={slug} key={`org-group-${id}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.group?.data ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="text-sm">
|
||||
All groups in your organization have already been added to this project.
|
||||
</div>
|
||||
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
|
||||
<Button variant="outline_bg">Create a new group</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+13
-15
@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
@@ -195,19 +195,18 @@ type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
identityId: string;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
groupSlug: string;
|
||||
roles: TGroupMembership["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const IdentityRoles = ({
|
||||
export const GroupRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
identityId
|
||||
groupSlug
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
@@ -226,7 +225,7 @@ export const IdentityRoles = ({
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
|
||||
const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
@@ -252,16 +251,16 @@ export const IdentityRoles = ({
|
||||
});
|
||||
|
||||
try {
|
||||
await updateIdentityWorkspaceRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId,
|
||||
await updateGroupWorkspaceRole.mutateAsync({
|
||||
projectSlug: currentWorkspace?.slug || "",
|
||||
groupSlug,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated identity role", type: "success" });
|
||||
createNotification({ text: "Successfully updated group role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update identity role", type: "error" });
|
||||
createNotification({ text: "Failed to update group role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -410,7 +409,6 @@ export const IdentityRoles = ({
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription,useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
|
||||
|
||||
import { GroupModal } from "./GroupModal";
|
||||
import { GroupTable } from "./GroupsTable";
|
||||
|
||||
export const GroupsSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"group",
|
||||
"deleteGroup",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const handleAddGroupModal = () => {
|
||||
if (!subscription?.groups) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can manage users more efficiently with groups if you upgrade your Infisical plan."
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("group");
|
||||
}
|
||||
}
|
||||
|
||||
const onRemoveGroupSubmit = async (groupSlug: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
groupSlug,
|
||||
projectSlug: currentWorkspace?.slug || ""
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteGroup");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove group from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<GroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<GroupTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to remove the group ${
|
||||
(popUp?.deleteGroup?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveGroupSubmit(
|
||||
(popUp?.deleteGroup?.data as { slug: string })?.slug
|
||||
)
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupRoles } from "./GroupRoles";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteGroup", "group"]>,
|
||||
data?: {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useListWorkspaceGroups(currentWorkspace?.slug || "");
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map(({ group: { id, name, slug }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<GroupRoles roles={roles} disableEdit={!isAllowed} groupSlug={slug} />
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
slug,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data?.length === 0 && (
|
||||
<EmptyState title="No groups have been added to this project" icon={faServer} />
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { GroupsSection } from "./GroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupsSection } from "./GroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupsTab } from "./GroupsTab";
|
||||
@@ -1,19 +1,351 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faClock,
|
||||
faEdit,
|
||||
faPlus,
|
||||
faServer,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
IdentitySection,
|
||||
} from "./components";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
export const IdentityTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-identity"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<IdentitySection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
import { IdentityModal } from "./components/IdentityModal";
|
||||
import { IdentityRoleForm } from "./components/IdentityRoleForm";
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
export const IdentityTab = withProjectPermission(
|
||||
() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
"deleteIdentity",
|
||||
"upgradePlan",
|
||||
"updateRole"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (identityId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteIdentity");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="identity-role-panel"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("identity")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map((identityMember, index) => {
|
||||
const {
|
||||
identity: { id, name },
|
||||
roles,
|
||||
createdAt
|
||||
} = identityMember;
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
|
||||
<Td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Timed role expired"
|
||||
: "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Access expired"
|
||||
: "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(
|
||||
temporaryAccessEndTime as string
|
||||
) && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<Tooltip content="Edit permission">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
ariaLabel="update-role"
|
||||
onClick={() =>
|
||||
handlePopUpOpen("updateRole", { ...identityMember, index })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState
|
||||
title="No identities have been added to this project"
|
||||
icon={faServer}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Modal
|
||||
isOpen={popUp.updateRole.isOpen}
|
||||
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-3xl"
|
||||
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name
|
||||
}`}
|
||||
subTitle={`
|
||||
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
|
||||
`}
|
||||
>
|
||||
<IdentityRoleForm
|
||||
onOpenUpgradeModal={(description) =>
|
||||
handlePopUpOpen("upgradePlan", { description })
|
||||
}
|
||||
identityProjectMember={
|
||||
data?.[
|
||||
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index
|
||||
] as IdentityMembership
|
||||
}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveIdentitySubmit(
|
||||
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
|
||||
);
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId: identityProjectMember.identity.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"opacity-0 cursor-default",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
|
||||
import { IdentityRbacSection } from "./IdentityRbacSection";
|
||||
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
|
||||
|
||||
type Props = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<IdentityRbacSection
|
||||
identityProjectMember={identityProjectMember}
|
||||
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||
/>
|
||||
<SpecificPrivilegeSection identityId={identityProjectMember?.identity?.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useDeleteIdentityProjectAdditionalPrivilege,
|
||||
useUpdateIdentityProjectAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
const SpecificPrivilegeSecretForm = ({
|
||||
privilege,
|
||||
identityId
|
||||
}: {
|
||||
privilege: TProjectUserPrivilege;
|
||||
identityId: string;
|
||||
}) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
|
||||
const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||
if (updateIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await updateIdentityPrivilege.mutateAsync({
|
||||
privilegeDetails: {
|
||||
...data.temporaryAccess,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
}))
|
||||
},
|
||||
privilegeSlug: privilege.slug,
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (deleteIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
await deleteIdentityPrivilege.mutateAsync({
|
||||
identityId,
|
||||
privilegeSlug: privilege.slug,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
if (exactTime)
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Env">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path">
|
||||
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="read"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="View" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-read"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="create"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Create" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-create"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="edit"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Modify" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-modify"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="delete"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Delete" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-delete"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip content={getAccessLabel(true)}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = privilegeForm.getValues(
|
||||
"temporaryAccess.temporaryRange"
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
privilegeForm.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||
privilegeForm.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
privilegeForm.setValue("temporaryAccess", {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{privilegeForm.formState.isDirty ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
isDisabled={privilegeForm.formState.isSubmitting}
|
||||
onClick={() => privilegeForm.reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Save"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="border-none py-3"
|
||||
ariaLabel="save-privilege"
|
||||
type="submit"
|
||||
>
|
||||
{privilegeForm.formState.isSubmitting ? (
|
||||
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
title="Remove user additional privilege"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
deleteKey="delete"
|
||||
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||
onDeleteApproved={handleDeletePrivilege}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const SpecificPrivilegeSection = ({ identityId }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const handleCreatePrivilege = async () => {
|
||||
if (createIdentityPrivilege.isLoading) return;
|
||||
try {
|
||||
await createIdentityPrivilege.mutateAsync({
|
||||
permissions: [
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions: {
|
||||
environment: currentWorkspace?.environments?.[0].slug
|
||||
}
|
||||
}
|
||||
],
|
||||
identityId,
|
||||
projectSlug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
|
||||
<div className="flex items-center space-x-2 text-lg font-medium">
|
||||
Additional Privileges
|
||||
{isLoading && <Spinner size="xs" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-mineshaft-400">
|
||||
Select individual privileges to associate with the identity.
|
||||
</p>
|
||||
<div>
|
||||
{identityPrivileges
|
||||
?.filter(({ permissions }) =>
|
||||
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
|
||||
)
|
||||
?.map((privilege) => (
|
||||
<SpecificPrivilegeSecretForm
|
||||
privilege={privilege as TProjectUserPrivilege}
|
||||
identityId={identityId}
|
||||
key={privilege?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreatePrivilege}
|
||||
isLoading={createIdentityPrivilege.isLoading}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add additional privilege
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { IdentityRoleForm } from "./IdentityRoleForm";
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityModal } from "./IdentityModal";
|
||||
import { IdentityTable } from "./IdentityTable";
|
||||
|
||||
export const IdentitySection = withProjectPermission(
|
||||
() => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
"deleteIdentity",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (identityId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteIdentity");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("identity")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveIdentitySubmit(
|
||||
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
|
||||
);
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityRoles } from "./IdentityRoles";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
|
||||
data?: {
|
||||
identityId?: string;
|
||||
name?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map(({ identity: { id, name }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState title="No identities have been added to this project" icon={faServer} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
@@ -1 +0,0 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
@@ -1 +1 @@
|
||||
export { IdentityTab } from "./IdentityTab";
|
||||
export { IdentityTab } from "./IdentityTab";
|
||||
|
||||
+190
-44
@@ -2,18 +2,30 @@ import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faClock,
|
||||
faEdit,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faUsers,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -23,10 +35,12 @@ import {
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
@@ -46,9 +60,11 @@ import {
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { MemberRoles } from "./MemberRoles";
|
||||
import { MemberRoleForm } from "./MemberRoleForm";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMembershipId: z.string().trim()
|
||||
@@ -56,8 +72,15 @@ const addMemberFormSchema = z.object({
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
|
||||
export const MemberListTab = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
@@ -71,13 +94,14 @@ export const MemberListTab = () => {
|
||||
const { data: wsKey } = useGetUserWsKey(workspaceId);
|
||||
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
||||
const { data: orgUsers } = useGetOrgUsers(orgId);
|
||||
|
||||
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"removeMember",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"updateRole"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
@@ -105,7 +129,8 @@ export const MemberListTab = () => {
|
||||
const orgUser = (orgUsers || []).find(({ id }) => id === orgMembershipId);
|
||||
if (!orgUser) return;
|
||||
|
||||
try { // TODO: update
|
||||
try {
|
||||
// TODO: update
|
||||
if (currentWorkspace.version === ProjectVersion.V1) {
|
||||
await addUserToWorkspace({
|
||||
workspaceId,
|
||||
@@ -185,7 +210,14 @@ export const MemberListTab = () => {
|
||||
}, [orgUsers, members]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<motion.div
|
||||
key="user-role-1"
|
||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Members</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
|
||||
@@ -222,7 +254,8 @@ export const MemberListTab = () => {
|
||||
<TBody>
|
||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||
{!isMembersLoading &&
|
||||
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
|
||||
filterdUsers?.map((projectMember, index) => {
|
||||
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
|
||||
@@ -231,44 +264,136 @@ export const MemberListTab = () => {
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<MemberRoles
|
||||
roles={roles}
|
||||
disableEdit={u.id === user?.id || !isAllowed}
|
||||
onOpenUpgradeModal={(description) =>
|
||||
handlePopUpOpen("upgradePlan", { description })
|
||||
}
|
||||
membershipId={membershipId}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired ? "Timed role expired" : "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Access expired"
|
||||
: "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(
|
||||
temporaryAccessEndTime as string
|
||||
) && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{userId !== u?.id && (
|
||||
<Tooltip content="Edit permission">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
ariaLabel="update-role"
|
||||
onClick={() =>
|
||||
handlePopUpOpen("updateRole", { ...projectMember, index })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMember", { username: u.username })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMember", { username: u.username })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -342,6 +467,27 @@ export const MemberListTab = () => {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={popUp.updateRole.isOpen}
|
||||
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title={`Manage Access for ${(popUp.updateRole.data as TWorkspaceUser)?.user?.email}`}
|
||||
subTitle={`
|
||||
Configure role-based access control by assigning Infisical users a mix of roles and specific privileges. A user will gain access to all actions within the roles assigned to them, not just the actions those roles share in common. You must choose at least one permanent role.
|
||||
`}
|
||||
>
|
||||
<MemberRoleForm
|
||||
onOpenUpgradeModal={(description) => handlePopUpOpen("upgradePlan", { description })}
|
||||
projectMember={
|
||||
filterdUsers?.[
|
||||
(popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index
|
||||
] as TWorkspaceUser
|
||||
}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
@@ -354,6 +500,6 @@ export const MemberListTab = () => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId: projectMember.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"opacity-0 cursor-default",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { MemberRbacSection } from "./MemberRbacSection";
|
||||
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<MemberRbacSection
|
||||
projectMember={projectMember}
|
||||
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||
/>
|
||||
<SpecificPrivilegeSection membershipId={projectMember?.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useDeleteProjectUserAdditionalPrivilege,
|
||||
useListProjectUserPrivileges,
|
||||
useUpdateProjectUserAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
||||
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||
if (updateUserPrivilege.isLoading) return;
|
||||
try {
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await updateUserPrivilege.mutateAsync({
|
||||
privilegeId: privilege.id,
|
||||
...data.temporaryAccess,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
})),
|
||||
projectMembershipId: privilege.projectMembershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (deleteUserPrivilege.isLoading) return;
|
||||
try {
|
||||
await deleteUserPrivilege.mutateAsync({
|
||||
privilegeId: privilege.id,
|
||||
projectMembershipId: privilege.projectMembershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
if (exactTime)
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Env">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path">
|
||||
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="read"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="View" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-read"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="create"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Create" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-create"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="edit"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Modify" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-modify"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="delete"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Delete" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-delete"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip content={getAccessLabel(true)}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = privilegeForm.getValues(
|
||||
"temporaryAccess.temporaryRange"
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
privilegeForm.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||
privilegeForm.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
privilegeForm.setValue("temporaryAccess", {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{privilegeForm.formState.isDirty ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
isDisabled={privilegeForm.formState.isSubmitting}
|
||||
onClick={() => privilegeForm.reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Save"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="border-none py-3"
|
||||
ariaLabel="save-privilege"
|
||||
type="submit"
|
||||
>
|
||||
{privilegeForm.formState.isSubmitting ? (
|
||||
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
title="Remove user additional privilege"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
deleteKey="delete"
|
||||
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||
onDeleteApproved={handleDeletePrivilege}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
|
||||
const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const createUserPrivilege = useCreateProjectUserAdditionalPrivilege();
|
||||
|
||||
const handleCreatePrivilege = async () => {
|
||||
if (createUserPrivilege.isLoading) return;
|
||||
try {
|
||||
await createUserPrivilege.mutateAsync({
|
||||
permissions: [
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions: {
|
||||
environment: currentWorkspace?.environments?.[0].slug
|
||||
}
|
||||
}
|
||||
],
|
||||
projectMembershipId: membershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created privilege"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
|
||||
<div className="flex items-center space-x-2 text-lg font-medium">
|
||||
Additional Privileges
|
||||
{isLoading && <Spinner size="xs" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-mineshaft-400">
|
||||
Select individual privileges to associate with the user.
|
||||
</p>
|
||||
<div>
|
||||
{userPrivileges
|
||||
?.filter(({ permissions }) =>
|
||||
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
|
||||
)
|
||||
?.map((privilege) => (
|
||||
<SpecificPrivilegeSecretForm
|
||||
privilege={privilege as TProjectUserPrivilege}
|
||||
key={privilege?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreatePrivilege}
|
||||
isLoading={createUserPrivilege.isLoading}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add additional privilege
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MemberRoleForm } from "./MemberRoleForm";
|
||||
@@ -1,473 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const TemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
membershipId: string;
|
||||
onOpenUpgradeModal: (description: string) => void;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const MemberRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
membershipId,
|
||||
onOpenUpgradeModal
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = selectedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={isExpired ? "Access expired" : "Temporary access"}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<TemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@@ -29,7 +29,7 @@ type Props = {
|
||||
|
||||
export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
@@ -50,7 +50,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
||||
handlePopUpClose("deleteRole");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to create role" });
|
||||
createNotification({ type: "error", text: "Failed to delete role" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+9
-4
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { motion } from "framer-motion";
|
||||
@@ -28,6 +28,7 @@ type Props = {
|
||||
formName: "secrets";
|
||||
isNonEditable?: boolean;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
getValue: UseFormGetValues<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -44,6 +45,7 @@ enum Permission {
|
||||
export const MultiEnvProjectPermission = ({
|
||||
isNonEditable,
|
||||
setValue,
|
||||
getValue,
|
||||
control,
|
||||
formName,
|
||||
title,
|
||||
@@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
switch (val) {
|
||||
case Permission.NoAccess:
|
||||
setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
|
||||
case Permission.NoAccess: {
|
||||
const permissions = getValue("permissions");
|
||||
if (permissions) delete permissions[formName];
|
||||
setValue("permissions", permissions, { shouldDirty: true });
|
||||
break;
|
||||
}
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
@@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({
|
||||
className={twMerge(
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
|
||||
"border-l-2 border-primary-600"
|
||||
"border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
+12
-4
@@ -18,7 +18,7 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useCreateProjectRole, useUpdateProjectRole } from "@app/hooks/api";
|
||||
@@ -55,11 +55,17 @@ const SINGLE_PERMISSION_LIST = [
|
||||
formName: "role"
|
||||
},
|
||||
{
|
||||
title: "Project Members",
|
||||
subtitle: "Project members management control",
|
||||
title: "User management",
|
||||
subtitle: "Add, view and remove users from the project",
|
||||
icon: faUser,
|
||||
formName: "member"
|
||||
},
|
||||
{
|
||||
title: "Group management",
|
||||
subtitle: "Add, view and remove user groups from the project",
|
||||
icon: faUsers,
|
||||
formName: "groups"
|
||||
},
|
||||
{
|
||||
title: "Machine identity management",
|
||||
subtitle: "Add, view, update and remove (machine) identities from the project",
|
||||
@@ -119,7 +125,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(role?.slug || "");
|
||||
const isNewRole = !role?.slug;
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
@@ -128,6 +134,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
register,
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
setValue,
|
||||
getValues,
|
||||
control
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
|
||||
@@ -226,6 +233,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<MultiEnvProjectPermission
|
||||
getValue={getValues}
|
||||
isNonEditable={isNonEditable}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
|
||||
+2
@@ -31,11 +31,13 @@ export const formSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
|
||||
permissions: z
|
||||
.object({
|
||||
secrets: z.record(multiEnvPermissionSchema).optional(),
|
||||
member: generalPermissionSchema,
|
||||
groups: generalPermissionSchema,
|
||||
identity: generalPermissionSchema,
|
||||
role: generalPermissionSchema,
|
||||
integrations: generalPermissionSchema,
|
||||
|
||||
+4
-4
@@ -91,7 +91,7 @@ export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: P
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-10 py-6 bg-mineshaft-800 rounded-md",
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
@@ -99,8 +99,8 @@ export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: P
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col">
|
||||
<div className="font-medium mb-1 text-lg">Secret Rollback</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg font-medium">Secret Rollback</div>
|
||||
<div className="text-xs font-light">Secret rollback control actions</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -120,7 +120,7 @@ export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: P
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
||||
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
|
||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
||||
>
|
||||
{isCustom &&
|
||||
PERMISSIONS.map(({ action, label }) => (
|
||||
|
||||
+5
-4
@@ -15,6 +15,7 @@ type Props = {
|
||||
formName:
|
||||
| "role"
|
||||
| "member"
|
||||
| "groups"
|
||||
| "integrations"
|
||||
| "webhooks"
|
||||
| "service-tokens"
|
||||
@@ -135,7 +136,7 @@ export const SingleProjectPermission = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-10 py-6 bg-mineshaft-800 rounded-md",
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
@@ -143,8 +144,8 @@ export const SingleProjectPermission = ({
|
||||
<div>
|
||||
<FontAwesomeIcon icon={icon} className="text-4xl" />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col">
|
||||
<div className="font-medium mb-1 text-lg">{title}</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg font-medium">{title}</div>
|
||||
<div className="text-xs font-light">{subtitle}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -164,7 +165,7 @@ export const SingleProjectPermission = ({
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
||||
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
|
||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
||||
>
|
||||
{isCustom &&
|
||||
getPermissionList(formName).map(({ action, label }) => (
|
||||
|
||||
+4
-4
@@ -71,7 +71,7 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props)
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-10 py-6 bg-mineshaft-800 rounded-md",
|
||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
||||
)}
|
||||
>
|
||||
@@ -79,8 +79,8 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props)
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col">
|
||||
<div className="font-medium mb-1 text-lg">Project</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg font-medium">Project</div>
|
||||
<div className="text-xs font-light">Project control actions</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -99,7 +99,7 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props)
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
||||
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
|
||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
||||
>
|
||||
{isCustom &&
|
||||
PERMISSIONS.map(({ action, label }) => (
|
||||
|
||||
+13
-15
@@ -1,19 +1,17 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
ServiceTokenSection,
|
||||
} from "./components";
|
||||
import { ServiceTokenSection } from "./components";
|
||||
|
||||
export const ServiceTokenTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<ServiceTokenSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<ServiceTokenSection />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { AxiosError } from "axios";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
@@ -79,7 +79,7 @@ type Props = {
|
||||
|
||||
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
control,
|
||||
@@ -161,7 +161,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const axiosError = err as AxiosError
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError?.response?.status === 401) {
|
||||
createNotification({
|
||||
text: "You do not have access to the selected environment/path",
|
||||
|
||||
+3
-5
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
@@ -18,7 +18,7 @@ type DeleteModalData = { name: string; id: string };
|
||||
export const ServiceTokenSection = withProjectPermission(
|
||||
() => {
|
||||
const { t } = useTranslation();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const deleteServiceToken = useDeleteServiceToken();
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
@@ -49,9 +49,7 @@ export const ServiceTokenSection = withProjectPermission(
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">
|
||||
Service Tokens
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.ServiceTokens}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export {ServiceTokenSection} from "./ServiceTokenSection"
|
||||
export { ServiceTokenSection } from "./ServiceTokenSection";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user