mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 05:13:43 +00:00
Merge remote-tracking branch 'origin/main' into feat/azureDevopsSecretSync
This commit is contained in:
@@ -3,7 +3,7 @@ import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
@@ -51,6 +51,7 @@ export const SelectOrganizationSection = () => {
|
||||
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
|
||||
const router = useRouter();
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const orgId = queryParams.get("org_id");
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
@@ -118,6 +119,8 @@ export const SelectOrganizationSection = () => {
|
||||
})
|
||||
.finally(() => setIsInitialOrgCheckLoading(false));
|
||||
|
||||
await router.invalidate();
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
if (mfaMethod) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { SelectOrganizationPage } from "./SelectOrgPage";
|
||||
export const SelectOrganizationPageQueryParams = z.object({
|
||||
org_id: z.string().optional().catch(""),
|
||||
callback_port: z.coerce.number().optional().catch(undefined),
|
||||
is_admin_login: z.boolean().optional().catch(false)
|
||||
is_admin_login: z.boolean().optional().catch(false),
|
||||
force: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({
|
||||
|
||||
@@ -28,8 +28,7 @@ import {
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { navigateUserToOrg } from "../LoginPage/Login.utils";
|
||||
import { isLoggedIn } from "@app/hooks/api/reactQuery";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
@@ -71,6 +70,8 @@ export const SignupInvitePage = () => {
|
||||
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
// Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria.
|
||||
const signupErrorCheck = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -242,29 +243,10 @@ export const SignupInvitePage = () => {
|
||||
if (response?.token) {
|
||||
SecurityClient.setSignupToken(response.token);
|
||||
setStep(2);
|
||||
} else if (loggedIn) {
|
||||
navigate({ to: "/login/select-organization", search: { force: true } });
|
||||
} else {
|
||||
const redirectExistingUser = async () => {
|
||||
try {
|
||||
const { token: mfaToken, isMfaEnabled } = await selectOrganization({
|
||||
organizationId
|
||||
});
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(mfaToken);
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => redirectExistingUser);
|
||||
return;
|
||||
}
|
||||
|
||||
// user will be redirected to dashboard
|
||||
// if not logged in gets kicked out to login
|
||||
await navigateUserToOrg(navigate, organizationId);
|
||||
} catch (err) {
|
||||
navigate({ to: "/login" });
|
||||
}
|
||||
};
|
||||
|
||||
await redirectExistingUser();
|
||||
navigate({ to: "/login" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -22,8 +22,12 @@ export const VerifyEmailPage = () => {
|
||||
*/
|
||||
const sendVerificationEmail = async () => {
|
||||
if (email) {
|
||||
await mutateAsync({ email });
|
||||
setStep(2);
|
||||
try {
|
||||
await mutateAsync({ email });
|
||||
setStep(2);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
callback_port: z.coerce.number().optional().catch(undefined)
|
||||
callback_port: z.coerce.number().optional().catch(undefined),
|
||||
force: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const AuthConsentWrapper = () => {
|
||||
@@ -71,7 +72,7 @@ export const AuthConsentWrapper = () => {
|
||||
export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
validateSearch: zodValidator(QueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ callback_port: undefined })]
|
||||
middlewares: [stripSearchParams({ callback_port: undefined, force: undefined })]
|
||||
},
|
||||
beforeLoad: async ({ context, location, search }) => {
|
||||
if (!context.serverConfig.initialized) {
|
||||
@@ -90,6 +91,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
if (!data) return;
|
||||
|
||||
setAuthToken(data.token);
|
||||
|
||||
if (location.pathname === "/signupinvite") return;
|
||||
|
||||
// Avoid redirect if on select-organization page with force=true
|
||||
if (location.pathname.endsWith("select-organization") && search?.force === true) return;
|
||||
|
||||
// to do cli login
|
||||
if (search?.callback_port) {
|
||||
if (location.pathname.endsWith("select-organization") || location.pathname.endsWith("login"))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { OrgPermissionGuardBanner } from "@app/components/permissions/OrgPermissionCan";
|
||||
import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
@@ -72,6 +73,8 @@ export const AccessManagementPage = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const hasNoAccess = tabSections.every((tab) => tab.isHidden);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<Helmet>
|
||||
@@ -126,6 +129,7 @@ export const AccessManagementPage = () => {
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
{hasNoAccess && <OrgPermissionGuardBanner />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+198
-105
@@ -33,20 +33,26 @@ import {
|
||||
useGetIdentityKubernetesAuth,
|
||||
useUpdateIdentityKubernetesAuth
|
||||
} from "@app/hooks/api";
|
||||
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
|
||||
import {
|
||||
IdentityKubernetesAuthTokenReviewMode,
|
||||
IdentityTrustedIp
|
||||
} from "@app/hooks/api/identities/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityFormTab } from "./types";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
kubernetesHost: z.string().min(1),
|
||||
tokenReviewMode: z
|
||||
.nativeEnum(IdentityKubernetesAuthTokenReviewMode)
|
||||
.default(IdentityKubernetesAuthTokenReviewMode.Api),
|
||||
kubernetesHost: z.string().optional().nullable(),
|
||||
tokenReviewerJwt: z.string().optional(),
|
||||
gatewayId: z.string().optional().nullable(),
|
||||
allowedNames: z.string(),
|
||||
allowedNamespaces: z.string(),
|
||||
allowedAudience: z.string(),
|
||||
caCert: z.string(),
|
||||
caCert: z.string().optional(),
|
||||
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
|
||||
message: "Access Token TTL cannot be greater than 315360000"
|
||||
}),
|
||||
@@ -62,7 +68,26 @@ const schema = z
|
||||
)
|
||||
.min(1)
|
||||
})
|
||||
.required();
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api &&
|
||||
!data.kubernetesHost?.length
|
||||
) {
|
||||
ctx.addIssue({
|
||||
path: ["kubernetesHost"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When token review mode is set to API, a Kubernetes host must be provided"
|
||||
});
|
||||
}
|
||||
|
||||
if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) {
|
||||
ctx.addIssue({
|
||||
path: ["gatewayId"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When token review mode is set to Gateway, a gateway must be selected"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
@@ -100,11 +125,14 @@ export const IdentityKubernetesAuthForm = ({
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api,
|
||||
kubernetesHost: "",
|
||||
tokenReviewerJwt: "",
|
||||
allowedNames: "",
|
||||
@@ -128,6 +156,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({
|
||||
tokenReviewMode: data.tokenReviewMode,
|
||||
kubernetesHost: data.kubernetesHost,
|
||||
tokenReviewerJwt: data.tokenReviewerJwt,
|
||||
allowedNames: data.allowedNames,
|
||||
@@ -148,6 +177,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api,
|
||||
kubernetesHost: "",
|
||||
tokenReviewerJwt: "",
|
||||
allowedNames: "",
|
||||
@@ -173,6 +203,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
gatewayId,
|
||||
tokenReviewMode,
|
||||
accessTokenTrustedIps
|
||||
}: FormData) => {
|
||||
try {
|
||||
@@ -181,7 +212,13 @@ export const IdentityKubernetesAuthForm = ({
|
||||
if (data) {
|
||||
await updateMutateAsync({
|
||||
organizationId: orgId,
|
||||
kubernetesHost,
|
||||
...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api
|
||||
? {
|
||||
kubernetesHost: kubernetesHost || ""
|
||||
}
|
||||
: {
|
||||
kubernetesHost: null
|
||||
}),
|
||||
tokenReviewerJwt: tokenReviewerJwt || null,
|
||||
allowedNames,
|
||||
allowedNamespaces,
|
||||
@@ -189,6 +226,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
caCert,
|
||||
identityId,
|
||||
gatewayId: gatewayId || null,
|
||||
tokenReviewMode,
|
||||
accessTokenTTL: Number(accessTokenTTL),
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
@@ -198,13 +236,20 @@ export const IdentityKubernetesAuthForm = ({
|
||||
await addMutateAsync({
|
||||
organizationId: orgId,
|
||||
identityId,
|
||||
kubernetesHost: kubernetesHost || "",
|
||||
...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api
|
||||
? {
|
||||
kubernetesHost: kubernetesHost || ""
|
||||
}
|
||||
: {
|
||||
kubernetesHost: null
|
||||
}),
|
||||
tokenReviewerJwt: tokenReviewerJwt || undefined,
|
||||
allowedNames: allowedNames || "",
|
||||
allowedNamespaces: allowedNamespaces || "",
|
||||
allowedAudience: allowedAudience || "",
|
||||
gatewayId: gatewayId || null,
|
||||
caCert: caCert || "",
|
||||
tokenReviewMode,
|
||||
accessTokenTTL: Number(accessTokenTTL),
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
@@ -228,6 +273,8 @@ export const IdentityKubernetesAuthForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const tokenReviewMode = watch("tokenReviewMode");
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit, (fields) => {
|
||||
@@ -235,6 +282,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
[
|
||||
"kubernetesHost",
|
||||
"tokenReviewerJwt",
|
||||
"tokenReviewMode",
|
||||
"gatewayId",
|
||||
"accessTokenTTL",
|
||||
"accessTokenMaxTTL",
|
||||
@@ -253,37 +301,135 @@ export const IdentityKubernetesAuthForm = ({
|
||||
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={IdentityFormTab.Configuration}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="2592000"
|
||||
name="kubernetesHost"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Kubernetes Host / Base Kubernetes API URL "
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running 'kubectl cluster-info'"
|
||||
isRequired
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-full flex-1">
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
<Input {...field} placeholder="https://my-example-k8s-api-host.com" type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tokenReviewerJwt"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipClassName="max-w-md"
|
||||
label="Token Reviewer JWT"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
|
||||
>
|
||||
<Input {...field} placeholder="" type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
isOptional
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={(v) => {
|
||||
if (v !== "") {
|
||||
onChange(v);
|
||||
}
|
||||
|
||||
if (v === null) {
|
||||
setValue(
|
||||
"tokenReviewMode",
|
||||
IdentityKubernetesAuthTokenReviewMode.Api,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewayLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="tokenReviewMode"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
tooltipClassName="max-w-md"
|
||||
tooltipText="The method of which tokens are reviewed. If you select Gateway as Reviewer, the selected gateway will be used to review tokens with. If this option is enabled, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding."
|
||||
label="Review Mode"
|
||||
>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectItem value="gateway">Gateway as Reviewer</SelectItem>
|
||||
<SelectItem value="api">Manual Token Reviewer JWT (API)</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && (
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="2592000"
|
||||
name="kubernetesHost"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Kubernetes Host / Base Kubernetes API URL "
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running 'kubectl cluster-info'"
|
||||
isRequired
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://my-example-k8s-api-host.com"
|
||||
type="text"
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="tokenReviewerJwt"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipClassName="max-w-md"
|
||||
label="Token Reviewer JWT"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
|
||||
>
|
||||
<Input {...field} placeholder="" type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
@@ -300,61 +446,6 @@ export const IdentityKubernetesAuthForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
isOptional
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={(v) => {
|
||||
if (v !== "") {
|
||||
onChange(v);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewayLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="allowedNames"
|
||||
@@ -431,20 +522,22 @@ export const IdentityKubernetesAuthForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="caCert"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA Certificate"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
tooltipText="An optional PEM-encoded CA cert for the Kubernetes API server. This is used by the TLS client for secure communication with the Kubernetes API server."
|
||||
>
|
||||
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="caCert"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA Certificate"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
tooltipText="An optional PEM-encoded CA cert for the Kubernetes API server. This is used by the TLS client for secure communication with the Kubernetes API server."
|
||||
>
|
||||
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{accessTokenTrustedIpsFields.map(({ id }, index) => (
|
||||
<div className="mb-3 flex items-end space-x-2" key={id}>
|
||||
<Controller
|
||||
|
||||
+1
@@ -159,6 +159,7 @@ export const AddOrgMemberModal = ({
|
||||
text: "Failed to invite user to org",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverDetails?.emailConfigured) {
|
||||
|
||||
+101
-11
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEllipsis,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUsers
|
||||
@@ -19,7 +22,11 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -75,6 +82,10 @@ enum OrgMembersOrderBy {
|
||||
Email = "email"
|
||||
}
|
||||
|
||||
type Filter = {
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { subscription } = useSubscription();
|
||||
@@ -184,17 +195,29 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
setUserTablePreference("orgMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
roles: []
|
||||
});
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
members
|
||||
?.filter(
|
||||
({ user: u, inviteEmail }) =>
|
||||
?.filter(({ user: u, inviteEmail, role, roleId }) => {
|
||||
if (
|
||||
filter.roles.length &&
|
||||
!filter.roles.includes(role === "custom" ? findRoleFromId(roleId)!.slug : role)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
inviteEmail?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
@@ -217,7 +240,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
|
||||
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||
}),
|
||||
[members, search, orderDirection, orderBy]
|
||||
[members, search, orderDirection, orderBy, filter]
|
||||
);
|
||||
|
||||
const handleSort = (column: OrgMembersOrderBy) => {
|
||||
@@ -236,14 +259,81 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleRoleToggle = useCallback(
|
||||
(roleSlug: string) =>
|
||||
setFilter((state) => {
|
||||
const currentRoles = state.roles || [];
|
||||
|
||||
if (currentRoles.includes(roleSlug)) {
|
||||
return { ...state, roles: currentRoles.filter((role) => role !== roleSlug) };
|
||||
}
|
||||
return { ...state, roles: [...currentRoles, roleSlug] };
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const isTableFiltered = Boolean(filter.roles.length);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Users"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Roles
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Apply Roles to Filter Users
|
||||
</DropdownMenuLabel>
|
||||
{roles?.map(({ id, slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleRoleToggle(slug);
|
||||
}}
|
||||
key={id}
|
||||
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Helmet } from "react-helmet";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faInfoCircle,
|
||||
@@ -169,6 +170,12 @@ export const GatewayListPage = withPermission(
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faCopy} />}
|
||||
onClick={() => navigator.clipboard.writeText(el.id)}
|
||||
>
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.EditGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th />
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
|
||||
+51
-25
@@ -1,8 +1,17 @@
|
||||
import { faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
@@ -38,30 +47,47 @@ export const GroupMembershipRow = ({
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td className="justify-end">
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isOidcManageGroupMembershipsEnabled
|
||||
? "OIDC Group Membership Mapping Enabled. Remove user from this group in your OIDC provider."
|
||||
: "Remove user from group"
|
||||
}
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled}
|
||||
ariaLabel="Remove user from group"
|
||||
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserMinus} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isOidcManageGroupMembershipsEnabled
|
||||
? "OIDC Group Membership Mapping Enabled. Remove user from this group in your OIDC provider."
|
||||
: undefined
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
|
||||
isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled}
|
||||
>
|
||||
Remove User From Group
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
|
||||
+145
-112
@@ -3,6 +3,7 @@ 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 { PopperContentProps } from "@radix-ui/react-popper";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -28,6 +29,7 @@ import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
@@ -196,33 +198,38 @@ type TForm = z.infer<typeof formSchema>;
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
groupId: string;
|
||||
className?: string;
|
||||
roles: TGroupMembership["roles"];
|
||||
popperContentProps?: PopperContentProps;
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMemberRolesProp) => {
|
||||
type FormProps = {
|
||||
projectRoles: Omit<TProjectRole, "permissions">[] | undefined;
|
||||
roles: TGroupMembership["roles"];
|
||||
groupId: string;
|
||||
onClose: VoidFunction;
|
||||
};
|
||||
|
||||
const GroupRolesForm = ({ projectRoles, roles, groupId, onClose }: FormProps) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(
|
||||
currentWorkspace?.id ?? ""
|
||||
);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
@@ -253,7 +260,7 @@ export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMember
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated group role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
onClose();
|
||||
setSearchRoles("");
|
||||
} catch {
|
||||
createNotification({ text: "Failed to update group role", type: "error" });
|
||||
@@ -261,7 +268,120 @@ export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMember
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<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 }) => (
|
||||
<IdentityTemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
groupId,
|
||||
className,
|
||||
popperContentProps
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(
|
||||
currentWorkspace?.id ?? ""
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex items-center space-x-1", className)}>
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
@@ -325,119 +445,32 @@ export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMember
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<PopoverTrigger onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
<PopoverContent
|
||||
{...popperContentProps}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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 }) => (
|
||||
<IdentityTemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
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>
|
||||
<GroupRolesForm
|
||||
projectRoles={projectRoles}
|
||||
groupId={groupId}
|
||||
roles={roles}
|
||||
onClose={() => handlePopUpToggle("editRole")}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
+30
-2
@@ -8,6 +8,7 @@ import {
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
@@ -55,6 +56,7 @@ enum GroupsOrderBy {
|
||||
|
||||
export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
search,
|
||||
@@ -143,7 +145,32 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
.slice(offset, perPage * page)
|
||||
.map(({ group: { id, name }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="group h-10" key={`st-v3-${id}`}>
|
||||
<Tr
|
||||
className="group h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`st-v3-${id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/groups/$groupId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
groupId: id
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/groups/$groupId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
groupId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
@@ -165,7 +192,8 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Remove">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
id,
|
||||
name
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
text: "Failed to add user to project",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpToggle("addMember", false);
|
||||
reset();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EmptyState, PageHeader, Spinner } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetWorkspaceGroupMembershipDetails } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
import { GroupDetailsSection } from "./components/GroupDetailsSection";
|
||||
import { GroupMembersSection } from "./components/GroupMembersSection";
|
||||
|
||||
const Page = () => {
|
||||
const groupId = useParams({
|
||||
strict: false,
|
||||
select: (el) => el.groupId as string
|
||||
});
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: groupMembership, isPending } = useGetWorkspaceGroupMembershipDetails(
|
||||
currentWorkspace.id,
|
||||
groupId
|
||||
);
|
||||
|
||||
if (isPending)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-24">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
{groupMembership ? (
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader title={groupMembership.group.name} />
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<GroupDetailsSection groupMembership={groupMembership} />
|
||||
</div>
|
||||
<GroupMembersSection groupMembership={groupMembership} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Error: Unable to find the group." className="py-12" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupDetailsByIDPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Project Group" })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
passThrough={false}
|
||||
renderGuardBanner
|
||||
>
|
||||
<Page />
|
||||
</ProjectPermissionCan>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton
|
||||
} from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { GroupRoles } from "@app/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles";
|
||||
|
||||
type Props = {
|
||||
groupMembership: TGroupMembership;
|
||||
};
|
||||
|
||||
export const GroupDetailsSection = ({ groupMembership }: Props) => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"deleteGroup"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onRemoveGroupSubmit = async () => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
groupId: groupMembership.group.id,
|
||||
projectId: currentWorkspace.id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed group from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/${currentWorkspace.id}/access-management?selectedTab=groups`
|
||||
});
|
||||
|
||||
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="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Group Details</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton ariaLabel="Options" colorSchema="secondary" className="w-6" variant="plain">
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={() => handlePopUpOpen("deleteGroup")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Remove Group From Project
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Group ID</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{groupMembership.group.id}</p>
|
||||
<CopyButton
|
||||
value={groupMembership.group.id}
|
||||
name="Group ID"
|
||||
size="xs"
|
||||
variant="plain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{groupMembership.group.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{groupMembership.group.slug}</p>
|
||||
<CopyButton value={groupMembership.group.slug} name="Slug" size="xs" variant="plain" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Project Role</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Groups}>
|
||||
{(isAllowed) => (
|
||||
<GroupRoles
|
||||
className="mt-1"
|
||||
popperContentProps={{ side: "right" }}
|
||||
roles={groupMembership.roles}
|
||||
groupId={groupMembership.group.id}
|
||||
disableEdit={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Assigned to Project</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{format(groupMembership.createdAt, "M/d/yyyy")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure you want to remove the group ${
|
||||
groupMembership.group.name
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
buttonText="Remove"
|
||||
onDeleteApproved={onRemoveGroupSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
|
||||
import { GroupMembersTable } from "./GroupMembersTable";
|
||||
|
||||
type Props = {
|
||||
groupMembership: TGroupMembership;
|
||||
};
|
||||
|
||||
export const GroupMembersSection = ({ groupMembership }: Props) => {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Group Members</h3>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<GroupMembersTable groupMembership={groupMembership} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
ConfirmActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useAssumeProjectPrivileges } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useListProjectGroupUsers } from "@app/hooks/api/groups/queries";
|
||||
import { EFilterReturnedUsers, TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { GroupMembershipRow } from "./GroupMembershipRow";
|
||||
|
||||
type Props = {
|
||||
groupMembership: TGroupMembership;
|
||||
};
|
||||
|
||||
enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupMembersOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: groupMemberships, isPending } = useListProjectGroupUsers({
|
||||
id: groupMembership.group.id,
|
||||
groupSlug: groupMembership.group.slug,
|
||||
projectId: currentWorkspace.id,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: EFilterReturnedUsers.EXISTING_MEMBERS
|
||||
});
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
return groupMemberships && groupMemberships?.users
|
||||
? groupMemberships?.users
|
||||
?.filter((membership) => {
|
||||
const userSearchString = `${membership.firstName && membership.firstName} ${
|
||||
membership.lastName && membership.lastName
|
||||
} ${membership.email && membership.email} ${
|
||||
membership.username && membership.username
|
||||
}`;
|
||||
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
const membershipOneComparisonString = membershipOne.firstName
|
||||
? membershipOne.firstName
|
||||
: membershipOne.email;
|
||||
|
||||
const membershipTwoComparisonString = membershipTwo.firstName
|
||||
? membershipTwo.firstName
|
||||
: membershipTwo.email;
|
||||
|
||||
const comparison = membershipOneComparisonString
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwoComparisonString.toLowerCase());
|
||||
|
||||
return comparison;
|
||||
})
|
||||
: [];
|
||||
}, [groupMemberships, orderDirection, search]);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships?.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const assumePrivileges = useAssumeProjectPrivileges();
|
||||
|
||||
const handleAssumePrivileges = async () => {
|
||||
const { userId } = popUp?.assumePrivileges?.data as { userId: string };
|
||||
assumePrivileges.mutate(
|
||||
{
|
||||
actorId: userId,
|
||||
actorType: ActorType.USER,
|
||||
projectId: currentWorkspace.id
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "User privilege assumption has started"
|
||||
});
|
||||
|
||||
let overviewPage: string;
|
||||
|
||||
switch (currentWorkspace.type) {
|
||||
case ProjectType.SecretScanning:
|
||||
overviewPage = "data-sources";
|
||||
break;
|
||||
case ProjectType.CertificateManager:
|
||||
overviewPage = "subscribers";
|
||||
break;
|
||||
default:
|
||||
overviewPage = "overview";
|
||||
}
|
||||
|
||||
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/${overviewPage}`;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
|
||||
{!isPending &&
|
||||
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
|
||||
return (
|
||||
<GroupMembershipRow
|
||||
key={`user-group-membership-${userGroupMembership.id}`}
|
||||
user={userGroupMembership}
|
||||
onAssumePrivileges={(userId) => handlePopUpOpen("assumePrivileges", { userId })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships?.users.length
|
||||
? "No users match this search..."
|
||||
: "This group does not have any members yet"
|
||||
}
|
||||
icon={groupMemberships?.users.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<ConfirmActionModal
|
||||
isOpen={popUp.assumePrivileges.isOpen}
|
||||
confirmKey="assume"
|
||||
title="Do you want to assume privileges of this user?"
|
||||
subTitle="This will set your privileges to those of the user for the next hour."
|
||||
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
|
||||
onConfirmed={handleAssumePrivileges}
|
||||
buttonText="Confirm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
|
||||
type Props = {
|
||||
user: TGroupUser;
|
||||
onAssumePrivileges: (userId: string) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipRow = ({
|
||||
user: { firstName, lastName, joinedGroupAt, email, id },
|
||||
onAssumePrivileges
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionMemberActions.AssumePrivileges}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUser} />}
|
||||
onClick={() => onAssumePrivileges(id)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Assume Privileges
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { GroupMembersSection } from "./GroupMembersSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupDetailsSection } from "./GroupDetailsSection";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/cert-manager/$projectId/access-management",
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Groups"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/kms/$projectId/access-management",
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Groups"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/access-management",
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Groups"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/secret-scanning/$projectId/access-management",
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Groups"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/ssh/$projectId/access-management",
|
||||
params: {
|
||||
projectId: params.projectId
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Groups"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { faHome } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Home",
|
||||
icon: () => <FontAwesomeIcon icon={faHome} />,
|
||||
link: linkOptions({ to: "/organization/secret-manager/overview" })
|
||||
},
|
||||
{
|
||||
label: "Access Control",
|
||||
link: linkOptions({ to: "/organization/access-management" })
|
||||
},
|
||||
{
|
||||
label: "groups"
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { useGetProjectTypeFromRoute } from "@app/hooks";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import {
|
||||
EXCLUDED_PERMISSION_SUBS,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
ProjectTypePermissionSubjects,
|
||||
@@ -66,6 +67,7 @@ const Content = ({ onClose }: ContentProps) => {
|
||||
subject as ProjectPermissionSub
|
||||
] && (search ? title.toLowerCase().includes(search.toLowerCase()) : true)
|
||||
)
|
||||
.filter(([subject]) => !EXCLUDED_PERMISSION_SUBS.includes(subject as ProjectPermissionSub))
|
||||
.sort((a, b) => a[1].title.localeCompare(b[1].title))
|
||||
.map(([subject]) => subject);
|
||||
|
||||
|
||||
+54
-1
@@ -12,6 +12,7 @@ import {
|
||||
} from "@app/context";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
@@ -92,6 +93,11 @@ const SecretSyncPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CommitPolicyActionSchema = z.object({
|
||||
[ProjectPermissionCommitsActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionCommitsActions.PerformRollback]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretRotationPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretRotationActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: z.boolean().optional(),
|
||||
@@ -226,6 +232,23 @@ const ConditionSchema = z
|
||||
: el.rhs.trim().startsWith("/")
|
||||
),
|
||||
{ message: "Invalid Secret Path. Must start with '/'" }
|
||||
)
|
||||
.refine(
|
||||
(val) =>
|
||||
val
|
||||
.filter((el) => el.operator === PermissionConditionOperators.$EQ)
|
||||
.every((el) => !el.rhs.includes(",")),
|
||||
{ message: '"Equal" checks cannot contain comma separated values. Use "IN" operator instead.' }
|
||||
)
|
||||
.refine(
|
||||
(val) =>
|
||||
val
|
||||
.filter((el) => el.operator === PermissionConditionOperators.$NEQ)
|
||||
.every((el) => !el.rhs.includes(",")),
|
||||
{
|
||||
message:
|
||||
'"Not Equal" checks cannot contain comma separated values. Use "IN" operator with "Forbid" instead.'
|
||||
}
|
||||
);
|
||||
|
||||
export const projectRoleFormSchema = z.object({
|
||||
@@ -268,6 +291,7 @@ export const projectRoleFormSchema = z.object({
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Commits]: CommitPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Member]: MemberPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Groups]: GroupPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]),
|
||||
@@ -868,6 +892,17 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Commits) {
|
||||
const canRead = action.includes(ProjectPermissionCommitsActions.Read);
|
||||
const canPerformRollback = action.includes(ProjectPermissionCommitsActions.PerformRollback);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionCommitsActions.Read] = true;
|
||||
if (canPerformRollback)
|
||||
formVal[subject]![0][ProjectPermissionCommitsActions.PerformRollback] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.PkiSubscribers) {
|
||||
if (!formVal[subject]) formVal[subject] = [];
|
||||
|
||||
@@ -1015,6 +1050,8 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
|
||||
return permissions;
|
||||
};
|
||||
|
||||
export const EXCLUDED_PERMISSION_SUBS = [ProjectPermissionSub.SecretRollback];
|
||||
|
||||
export type TProjectPermissionObject = {
|
||||
[K in ProjectPermissionSub]: {
|
||||
title: string;
|
||||
@@ -1210,6 +1247,13 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Commits]: {
|
||||
title: "Commits",
|
||||
actions: [
|
||||
{ label: "View", value: ProjectPermissionCommitsActions.Read },
|
||||
{ label: "Perform Rollback", value: ProjectPermissionCommitsActions.PerformRollback }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Tags]: {
|
||||
title: "Tags",
|
||||
actions: [
|
||||
@@ -1501,7 +1545,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({
|
||||
[ProjectPermissionSub.IpAllowList]: enabled,
|
||||
[ProjectPermissionSub.SecretRollback]: enabled,
|
||||
[ProjectPermissionSub.SecretRotation]: enabled,
|
||||
[ProjectPermissionSub.ServiceTokens]: enabled
|
||||
[ProjectPermissionSub.ServiceTokens]: enabled,
|
||||
[ProjectPermissionSub.Commits]: enabled
|
||||
});
|
||||
|
||||
const KmsPermissionSubjects = (enabled = false) => ({
|
||||
@@ -1881,6 +1926,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretSyncs,
|
||||
actions: [ProjectPermissionSecretSyncActions.Read]
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.Commits,
|
||||
actions: [ProjectPermissionCommitsActions.Read]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1938,6 +1987,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretSyncs,
|
||||
actions: Object.values(ProjectPermissionSecretSyncActions)
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.Commits,
|
||||
actions: Object.values(ProjectPermissionCommitsActions)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PermissionEmptyState } from "./PermissionEmptyState";
|
||||
import { PkiSubscriberPermissionConditions } from "./PkiSubscriberPermissionConditions";
|
||||
import { PkiTemplatePermissionConditions } from "./PkiTemplatePermissionConditions";
|
||||
import {
|
||||
EXCLUDED_PERMISSION_SUBS,
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
@@ -176,6 +177,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
<div className="py-4">
|
||||
{!isPending && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[])
|
||||
.filter((subject) => !EXCLUDED_PERMISSION_SUBS.includes(subject))
|
||||
.filter((subject) => ProjectTypePermissionSubjects[currentWorkspace.type][subject])
|
||||
.map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { CommitDetailsTab } from "./components/CommitDetailsTab";
|
||||
|
||||
export const CommitDetailsPage = () => {
|
||||
const envSlug = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.environment
|
||||
});
|
||||
const selectedCommitId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.commitId
|
||||
});
|
||||
const folderId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.folderId
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const routerQueryParams: { secretPath?: string } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id
|
||||
});
|
||||
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const handleGoBackToHistory = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToRollbackPreview = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId/$commitId/restore` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug,
|
||||
commitId: selectedCommitId
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl justify-center bg-bunker-800 pb-4 pt-2 text-white">
|
||||
<div className="w-full max-w-[75vw]">
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionCommitsActions.Read}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
<CommitDetailsTab
|
||||
selectedCommitId={selectedCommitId}
|
||||
workspaceId={currentWorkspace.id}
|
||||
goBackToHistory={handleGoBackToHistory}
|
||||
envSlug={envSlug}
|
||||
goToRollbackPreview={handleGoToRollbackPreview}
|
||||
/>
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Spinner
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { CommitWithChanges } from "@app/hooks/api/folderCommits";
|
||||
import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries";
|
||||
import { CommitType } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretVersionDiffView } from "../SecretVersionDiffView";
|
||||
import { MergedItem } from "./types";
|
||||
|
||||
const formatDisplayDate = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true
|
||||
};
|
||||
return new Intl.DateTimeFormat("en-US", options).format(date);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const CommitDetailsTab = ({
|
||||
selectedCommitId,
|
||||
workspaceId,
|
||||
envSlug,
|
||||
goBackToHistory,
|
||||
goToRollbackPreview
|
||||
}: {
|
||||
selectedCommitId: string;
|
||||
workspaceId: string;
|
||||
envSlug: string;
|
||||
goBackToHistory: () => void;
|
||||
goToRollbackPreview: () => void;
|
||||
}): JSX.Element => {
|
||||
// State for tracking collapsed items (empty by default means all are expanded)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"revertChanges"
|
||||
] as const);
|
||||
|
||||
const { data: commitDetails, isLoading } = useGetCommitDetails(workspaceId, selectedCommitId);
|
||||
|
||||
const routerQueryParams: { secretPath?: string } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id
|
||||
});
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const { mutateAsync: revert } = useCommitRevert({
|
||||
commitId: selectedCommitId,
|
||||
projectId: workspaceId,
|
||||
environment: envSlug,
|
||||
directory: secretPath
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedItems({});
|
||||
}, [selectedCommitId]);
|
||||
|
||||
const toggleItemCollapsed = (itemId: string): void => {
|
||||
setCollapsedItems((prev) => ({ ...prev, [itemId]: !prev[itemId] }));
|
||||
};
|
||||
|
||||
const handleRevertChanges = async (): Promise<void> => {
|
||||
const response = await revert();
|
||||
if (!response.success) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: response.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: response.message
|
||||
});
|
||||
|
||||
handlePopUpClose("revertChanges");
|
||||
|
||||
goBackToHistory();
|
||||
};
|
||||
|
||||
// If no commit is selected or data is loading, show appropriate message
|
||||
if (!selectedCommitId) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Select a commit to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!commitDetails) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">No details found for this commit</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the commit details if it's a string
|
||||
let parsedCommitDetails: CommitWithChanges;
|
||||
try {
|
||||
parsedCommitDetails =
|
||||
typeof commitDetails === "string" ? JSON.parse(commitDetails) : commitDetails;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse commit details:", error);
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Error parsing commit details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get all changes from the commit
|
||||
const commitChanges = parsedCommitDetails.changes?.changes || [];
|
||||
|
||||
// Separate changes by type
|
||||
const addedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && !c.isUpdate);
|
||||
const updatedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && c.isUpdate);
|
||||
const deletedChanges = commitChanges.filter((c) => c.changeType === CommitType.DELETE);
|
||||
|
||||
// Create merged item list from changes only
|
||||
const changedItems: MergedItem[] = [];
|
||||
|
||||
// Add items from added changes
|
||||
addedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
versionId: change.secretVersionId || change.id,
|
||||
folderName: change.folderName,
|
||||
folderVersion: change.folderVersion,
|
||||
secretKey: change.secretKey,
|
||||
secretVersion: change.secretVersion,
|
||||
isAdded: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Add items from updated changes
|
||||
updatedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
versionId: change.secretVersionId || change.id,
|
||||
folderName: change.folderName,
|
||||
folderVersion: change.folderVersion,
|
||||
secretKey: change.secretKey,
|
||||
secretVersion: change.secretVersion,
|
||||
isUpdated: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Add deleted items
|
||||
deletedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
secretKey: change.secretKey,
|
||||
folderName: change.folderName,
|
||||
secretVersion: change.secretVersion,
|
||||
folderVersion: change.folderVersion,
|
||||
isDeleted: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Sort items: deleted first, then folders, then secrets, all alphabetically
|
||||
const sortedChangedItems = [...changedItems].sort((a, b) => {
|
||||
// First sort deleted items to the top
|
||||
if (a.isDeleted !== b.isDeleted) {
|
||||
return a.isDeleted ? -1 : 1;
|
||||
}
|
||||
|
||||
// Then sort by type (folders before secrets)
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "folder" ? -1 : 1;
|
||||
}
|
||||
|
||||
// Finally sort alphabetically by name
|
||||
const aName = a.type === "folder" ? a.folderName || "" : a.secretKey || "";
|
||||
const bName = b.type === "folder" ? b.folderName || "" : b.secretKey || "";
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
// Render an item from the merged list
|
||||
const renderMergedItem = (item: MergedItem): JSX.Element => {
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<SecretVersionDiffView
|
||||
item={item}
|
||||
isCollapsed={collapsedItems[item.id]}
|
||||
onToggleCollapse={(id) => toggleItemCollapsed(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Format actor display
|
||||
const actorDisplay =
|
||||
parsedCommitDetails.changes?.actorMetadata?.name ||
|
||||
parsedCommitDetails.changes?.actorType ||
|
||||
"Unknown";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<div className="flex justify-between pb-2">
|
||||
<div className="w-5/6">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h1 className="mr-4 truncate text-3xl font-semibold text-white">
|
||||
{parsedCommitDetails.changes?.message || "No message"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-small mb-4 mt-2 flex items-center text-sm">
|
||||
<p>
|
||||
<span> Commited by </span>
|
||||
<b>{actorDisplay}</b>
|
||||
<span> on </span>
|
||||
<b>
|
||||
{formatDisplayDate(
|
||||
parsedCommitDetails.changes?.createdAt || new Date().toISOString()
|
||||
)}
|
||||
</b>
|
||||
{parsedCommitDetails.changes?.isLatest && (
|
||||
<span className="ml-1 italic text-gray-400">(Latest)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-start">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCommitsActions.PerformRollback}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isAllowed}
|
||||
className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<IconButton
|
||||
ariaLabel="commit-options"
|
||||
variant="outline_bg"
|
||||
className="h-10 rounded border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<p className="mr-2">Restore Options</p>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={2}
|
||||
className="animate-in fade-in-50 zoom-in-95 min-w-[240px] rounded-md bg-mineshaft-800 p-1 shadow-lg"
|
||||
style={{ marginTop: "0" }}
|
||||
>
|
||||
{!parsedCommitDetails.changes.isLatest && (
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => goToRollbackPreview()}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Roll back to this commit
|
||||
</span>
|
||||
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Return this folder to its exact state at the time of this commit,
|
||||
discarding all other changes made after it
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => handlePopUpOpen("revertChanges")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">Revert changes</span>
|
||||
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Will restore to the previous version of affected resources
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-2">
|
||||
{sortedChangedItems.length > 0 ? (
|
||||
sortedChangedItems.map((item) => renderMergedItem(item))
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-mineshaft-600 bg-mineshaft-800">
|
||||
<p className="text-gray-400">No changed items found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.revertChanges.isOpen}
|
||||
deleteKey="revert"
|
||||
title="Are you sure you want to revert all changes made in this commit?"
|
||||
subTitle="This will undo all changes made in this commit."
|
||||
onChange={(isOpen) => handlePopUpToggle("revertChanges", isOpen)}
|
||||
onDeleteApproved={handleRevertChanges}
|
||||
buttonText="Yes, revert changes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CommitDetailsTab } from "./CommitDetailsTab";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
export interface MergedItem {
|
||||
id: string;
|
||||
type: "secret" | "folder";
|
||||
versionId?: string;
|
||||
folderName?: string;
|
||||
folderVersion?: string;
|
||||
secretKey?: string;
|
||||
secretVersion?: string;
|
||||
isAdded?: boolean;
|
||||
isUpdated?: boolean;
|
||||
isDeleted?: boolean;
|
||||
versions?: any[];
|
||||
changeId: string;
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { useEffect, useState } from "react";
|
||||
import { faFolder, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
Input,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCommitRollback, useGetRollbackPreview } from "@app/hooks/api/folderCommits/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { SecretVersionDiffView } from "../SecretVersionDiffView";
|
||||
|
||||
interface Version {
|
||||
// Common fields
|
||||
id?: string;
|
||||
version: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
// Secret-specific fields
|
||||
secretKey?: string;
|
||||
secretValue?: string;
|
||||
secretComment?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
secretReminderRecipients?: string[];
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
// Folder-specific fields
|
||||
name?: string;
|
||||
envId?: string;
|
||||
folderId?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface RollbackChange {
|
||||
type: "secret" | "folder";
|
||||
id: string;
|
||||
versionId: string;
|
||||
fromVersion?: number;
|
||||
changeType: "create" | "update" | "delete";
|
||||
commitId: string;
|
||||
secretKey?: string;
|
||||
secretVersion?: number;
|
||||
folderName?: string;
|
||||
folderVersion?: number;
|
||||
versions?: Version[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface FolderChanges {
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
changes: RollbackChange[];
|
||||
}
|
||||
|
||||
export const RollbackPreviewTab = (): JSX.Element => {
|
||||
const [deepRollback, setDeepRollback] = useState<boolean>(false);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const envSlug = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.environment
|
||||
});
|
||||
const selectedCommitId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.commitId
|
||||
});
|
||||
const folderId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.folderId
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const routerQueryParams = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id
|
||||
});
|
||||
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const goBackToHistory = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"rollbackConfirm"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: rollback } = useCommitRollback({
|
||||
workspaceId: currentWorkspace.id,
|
||||
commitId: selectedCommitId,
|
||||
folderId,
|
||||
deepRollback,
|
||||
environment: envSlug,
|
||||
directory: secretPath,
|
||||
envSlug
|
||||
});
|
||||
|
||||
const { data: rollbackChangesNested, isLoading } = useGetRollbackPreview(
|
||||
folderId,
|
||||
selectedCommitId,
|
||||
envSlug,
|
||||
currentWorkspace.id,
|
||||
deepRollback,
|
||||
secretPath
|
||||
);
|
||||
|
||||
const handleRollback = async (): Promise<void> => {
|
||||
try {
|
||||
await rollback(message);
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Rollback completed successfully"
|
||||
});
|
||||
|
||||
handlePopUpClose("rollbackConfirm");
|
||||
goBackToHistory();
|
||||
} catch (error) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "Failed to rollback changes"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const folderChanges: FolderChanges[] = rollbackChangesNested || [];
|
||||
|
||||
const currentFolderChanges: FolderChanges = folderChanges.find(
|
||||
(folder) => folder.folderId === folderId
|
||||
) || {
|
||||
folderId: folderId || "",
|
||||
folderName: "Current Folder",
|
||||
folderPath: secretPath,
|
||||
changes: []
|
||||
};
|
||||
const nestedFolderChanges: FolderChanges[] = folderChanges.filter(
|
||||
(folder) => folder.folderId !== folderId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Select the current folder by default
|
||||
if (folderChanges.length > 0) {
|
||||
setSelectedFolderId(currentFolderChanges.folderId);
|
||||
}
|
||||
}, [folderChanges, currentFolderChanges.folderId]);
|
||||
|
||||
if (!selectedCommitId) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Select a commit to view rollback preview</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSidebar = (): JSX.Element => {
|
||||
return (
|
||||
<div className="h-[70vh] w-72 overflow-y-auto bg-mineshaft-900">
|
||||
<div
|
||||
className={`cursor-pointer border-b border-mineshaft-600 ${
|
||||
selectedFolderId === currentFolderChanges.folderId ? "bg-mineshaft-700" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedFolderId(currentFolderChanges.folderId)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" />
|
||||
<span className="font-medium text-white">
|
||||
{currentFolderChanges.folderPath || currentFolderChanges.folderName}
|
||||
</span>
|
||||
</div>
|
||||
{currentFolderChanges.changes.length > 0 && (
|
||||
<span className="ml-2 rounded-full bg-mineshaft-600 px-2 py-0.5 text-xs text-gray-300">
|
||||
{currentFolderChanges.changes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deepRollback && nestedFolderChanges.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-mineshaft-600 bg-mineshaft-800 px-4 py-2">
|
||||
<span className="text-sm font-semibold text-white">Child folders to be restored</span>
|
||||
</div>
|
||||
{nestedFolderChanges.map((folder) => (
|
||||
<div
|
||||
key={folder.folderId}
|
||||
className={`cursor-pointer border-b border-mineshaft-600 ${
|
||||
selectedFolderId === folder.folderId ? "bg-mineshaft-700" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedFolderId(folder.folderId)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" size="sm" />
|
||||
<span className="max-w-[150px] truncate text-sm font-medium text-white">
|
||||
{folder.folderPath || folder.folderName}
|
||||
</span>
|
||||
</div>
|
||||
{folder.changes.length > 0 && (
|
||||
<span className="rounded-full bg-mineshaft-600 px-2 py-0.5 text-xs text-gray-300">
|
||||
{folder.changes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectedFolderChanges = (): RollbackChange[] => {
|
||||
if (!selectedFolderId) return [];
|
||||
|
||||
const folder = folderChanges.find((f) => f.folderId === selectedFolderId);
|
||||
return folder?.changes || [];
|
||||
};
|
||||
|
||||
const renderMainContent = (): JSX.Element => {
|
||||
const selectedFolderChanges = getSelectedFolderChanges();
|
||||
const selectedFolder = folderChanges.find((f) => f.folderId === selectedFolderId);
|
||||
|
||||
if (!selectedFolder || selectedFolderChanges.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[70vh] w-full items-center justify-center border border-mineshaft-600">
|
||||
<p className="text-gray-400">No changes in selected folder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[70vh] w-full overflow-y-auto border-l border-mineshaft-600">
|
||||
<div className="space-y-4 p-4">
|
||||
{selectedFolderChanges.map((change) => (
|
||||
<div key={change.id} className="mb-4">
|
||||
<SecretVersionDiffView
|
||||
item={{
|
||||
id: change.id,
|
||||
type: change.type,
|
||||
isAdded: change.changeType === "create",
|
||||
isDeleted: change.changeType === "delete",
|
||||
isUpdated: change.changeType === "update",
|
||||
versions: change.versions,
|
||||
isRollback: true,
|
||||
secretKey: change.secretKey,
|
||||
folderName: change.folderName
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl justify-center bg-bunker-800 pb-4 pt-2 text-white">
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionCommitsActions.PerformRollback}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
<div className="w-full max-w-[75vw]">
|
||||
<div className="h-full w-full">
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`Restore folder at commit ${selectedCommitId.substring(0, 8)}`}
|
||||
description={`Will return all changes in this folder to how they appeared at the point of commit ${selectedCommitId.substring(0, 8)}. Any modifications made after this commit will be undone.`}
|
||||
/>
|
||||
|
||||
<div className="flex w-full border border-mineshaft-600">
|
||||
{renderSidebar()}
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
|
||||
<div className="border-x border-mineshaft-600 bg-mineshaft-800 px-6 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center">
|
||||
<Tooltip content="Will rollback all nested folders to their state at the time of this commit">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
className="ml-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/50"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={deepRollback}
|
||||
onCheckedChange={setDeepRollback}
|
||||
id="deep-rollback"
|
||||
>
|
||||
Restore All Child Folders
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-mineshaft-600 py-4">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Input
|
||||
placeholder="Restore Message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full border-mineshaft-500 bg-mineshaft-700 py-2 text-sm"
|
||||
maxLength={256}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("rollbackConfirm");
|
||||
}}
|
||||
colorSchema="primary"
|
||||
className="px-6 py-2"
|
||||
isDisabled={
|
||||
message.length === 0 ||
|
||||
!rollbackChangesNested ||
|
||||
!rollbackChangesNested?.some((folder) => {
|
||||
return folder.changes.length > 0;
|
||||
})
|
||||
}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.rollbackConfirm.isOpen}
|
||||
deleteKey="restore"
|
||||
title={`Are you sure you want to restore to commit ${selectedCommitId?.substring(0, 8)}?`}
|
||||
subTitle={`
|
||||
${
|
||||
deepRollback
|
||||
? "This will revert this folder and all child folders to how they appeared at the point in time of this commit."
|
||||
: "This will revert all changes to how they appeared at the point in time of this commit."
|
||||
}
|
||||
Any changes made after this commit will be permanently removed.
|
||||
`}
|
||||
onChange={(isOpen) => handlePopUpToggle("rollbackConfirm", isOpen)}
|
||||
onDeleteApproved={handleRollback}
|
||||
buttonText="Restore"
|
||||
/>
|
||||
</div>{" "}
|
||||
</div>{" "}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { RollbackPreviewTab } from "./RollbackPreviewTab";
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
|
||||
import { BreadcrumbTypes } from "@app/components/v2";
|
||||
|
||||
import { RollbackPreviewTab } from "./RollbackPreviewTab";
|
||||
|
||||
const RollbackPreviewTabQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore"
|
||||
)({
|
||||
component: RollbackPreviewTab,
|
||||
validateSearch: zodValidator(RollbackPreviewTabQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
type: BreadcrumbTypes.Dropdown,
|
||||
label:
|
||||
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
|
||||
dropdownTitle: "Environments",
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
}
|
||||
})
|
||||
}))
|
||||
},
|
||||
...secretPathSegments.map((_, index) => ({
|
||||
type: BreadcrumbTypes.Component,
|
||||
component: () => (
|
||||
<SecretDashboardPathBreadcrumb
|
||||
secretPathSegments={secretPathSegments}
|
||||
selectedPathSegmentIndex={index}
|
||||
environmentSlug={params.environment}
|
||||
projectId={params.projectId}
|
||||
disableCopy
|
||||
/>
|
||||
)
|
||||
})),
|
||||
{
|
||||
label: "Commits",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: params.commitId,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId/$commitId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId,
|
||||
commitId: params.commitId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Restore"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export interface Version {
|
||||
id?: string;
|
||||
version: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
type JsonArray = JsonValue[];
|
||||
|
||||
export interface DiffViewItem {
|
||||
type: "secret" | "folder";
|
||||
isAdded?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isUpdated?: boolean;
|
||||
versions?: Version[];
|
||||
isRollback?: boolean;
|
||||
id: string;
|
||||
secretKey?: string;
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
interface SecretVersionDiffViewProps {
|
||||
item: DiffViewItem;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: (id: string) => void;
|
||||
showHeader?: boolean;
|
||||
customHeader?: JSX.Element;
|
||||
excludedFieldsHighlight?: string[];
|
||||
}
|
||||
|
||||
const isObject = (obj: JsonValue): obj is JsonObject => {
|
||||
return obj !== null && typeof obj === "object" && !Array.isArray(obj);
|
||||
};
|
||||
|
||||
const isArray = (obj: JsonValue): obj is JsonArray => {
|
||||
return Array.isArray(obj);
|
||||
};
|
||||
|
||||
const deepEqual = (a: JsonValue, b: JsonValue): boolean => {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (isArray(a) && isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item: JsonValue, index: number) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
if (isObject(a) && isObject(b)) {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key]));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getDiffPaths = (oldObj: JsonValue, newObj: JsonValue, path: string = ""): Set<string> => {
|
||||
const diffPaths = new Set<string>();
|
||||
|
||||
if (oldObj === newObj) return diffPaths;
|
||||
|
||||
if (oldObj == null || newObj == null) {
|
||||
diffPaths.add(path || "root");
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (typeof oldObj !== typeof newObj) {
|
||||
diffPaths.add(path || "root");
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (isArray(oldObj) && isArray(newObj)) {
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (isObject(oldObj) && isObject(newObj)) {
|
||||
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (path.includes("[")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(key in oldObj) || !(key in newObj) || !deepEqual(oldObj[key], newObj[key])) {
|
||||
diffPaths.add(currentPath);
|
||||
|
||||
if (
|
||||
key in oldObj &&
|
||||
key in newObj &&
|
||||
(isObject(oldObj[key]) || isArray(oldObj[key])) &&
|
||||
(isObject(newObj[key]) || isArray(newObj[key]))
|
||||
) {
|
||||
const nestedDiffs = getDiffPaths(oldObj[key], newObj[key], currentPath);
|
||||
nestedDiffs.forEach((p) => diffPaths.add(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (oldObj !== newObj) {
|
||||
diffPaths.add(path || "root");
|
||||
}
|
||||
|
||||
return diffPaths;
|
||||
};
|
||||
|
||||
const getNestedValue = (obj: JsonValue, path: string): JsonValue => {
|
||||
if (!path) return obj;
|
||||
|
||||
const parts = path.split(/[.[\]]+/).filter(Boolean);
|
||||
let current: JsonValue = obj;
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
if (isObject(current)) {
|
||||
current = current[part];
|
||||
} else if (isArray(current)) {
|
||||
const index = parseInt(part, 10);
|
||||
if (!Number.isNaN(index)) {
|
||||
current = current[index];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const isPathDifferent = (jsonPath: string, diffPaths: Set<string>): boolean => {
|
||||
if (diffPaths.has(jsonPath)) return true;
|
||||
|
||||
const diffPathsArray = Array.from(diffPaths);
|
||||
return diffPathsArray.some((diffPath) => {
|
||||
return (
|
||||
jsonPath.startsWith(`${diffPath}.`) ||
|
||||
jsonPath.startsWith(`${diffPath}[`) ||
|
||||
diffPath.startsWith(`${jsonPath}.`) ||
|
||||
diffPath.startsWith(`${jsonPath}[`)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isContainerActuallyChanged = (
|
||||
path: string,
|
||||
diffPaths: Set<string>,
|
||||
oldObj: JsonValue,
|
||||
newObj: JsonValue
|
||||
): boolean => {
|
||||
if (diffPaths.has(path)) {
|
||||
if (oldObj == null || newObj == null) return true;
|
||||
if (typeof oldObj !== typeof newObj) return true;
|
||||
if (isArray(oldObj) !== isArray(newObj)) return true;
|
||||
if (isObject(oldObj) !== isObject(newObj)) return true;
|
||||
|
||||
if (!isObject(oldObj) && !isArray(oldObj)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isArray(oldObj) && isArray(newObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isObject(oldObj) && isObject(newObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return oldObj !== newObj;
|
||||
};
|
||||
|
||||
const renderJsonWithDiffs = (
|
||||
obj: JsonValue,
|
||||
diffPaths: Set<string>,
|
||||
isOldVersion: boolean,
|
||||
path: string = "",
|
||||
indentLevel: number = 0,
|
||||
keyName?: string,
|
||||
isLastItem: boolean = false,
|
||||
excludedFieldsHighlight: string[] = [],
|
||||
oldVersionObj?: JsonValue,
|
||||
newVersionObj?: JsonValue
|
||||
): JSX.Element => {
|
||||
const indent = " ".repeat(indentLevel);
|
||||
|
||||
let isDifferent = false;
|
||||
|
||||
if (path.includes("[") && oldVersionObj && newVersionObj) {
|
||||
const arrayMatch = path.match(/^([^[]+)\[(\d+)\]/);
|
||||
if (arrayMatch) {
|
||||
const arrayPath = arrayMatch[1];
|
||||
const itemIndex = parseInt(arrayMatch[2], 10);
|
||||
|
||||
const oldArray = getNestedValue(oldVersionObj, arrayPath);
|
||||
const newArray = getNestedValue(newVersionObj, arrayPath);
|
||||
|
||||
if (isArray(oldArray) && isArray(newArray)) {
|
||||
const currentItem = isOldVersion ? oldArray[itemIndex] : newArray[itemIndex];
|
||||
|
||||
if (isOldVersion) {
|
||||
isDifferent = !newArray.some((newItem: JsonValue) => deepEqual(currentItem, newItem));
|
||||
} else {
|
||||
isDifferent = !oldArray.some((oldItem: JsonValue) => deepEqual(currentItem, oldItem));
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
|
||||
const getLineClass = (different: boolean) => {
|
||||
if (!different) return "flex";
|
||||
return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300";
|
||||
};
|
||||
|
||||
const getHighlightClass = (different: boolean) => {
|
||||
if (!different) return "";
|
||||
return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1";
|
||||
};
|
||||
|
||||
const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " ";
|
||||
const keyDisplay = keyName ? `"${keyName}": ` : "";
|
||||
const comma = !isLastItem ? "," : "";
|
||||
|
||||
const reactKey = `${path || "root"}-${keyName || "value"}-${indentLevel}-${typeof obj}`;
|
||||
|
||||
if (
|
||||
obj === null ||
|
||||
typeof obj === "string" ||
|
||||
typeof obj === "number" ||
|
||||
typeof obj === "boolean"
|
||||
) {
|
||||
let valueDisplay = "";
|
||||
if (obj === null) valueDisplay = "null";
|
||||
else if (typeof obj === "string") valueDisplay = `"${obj}"`;
|
||||
else valueDisplay = String(obj);
|
||||
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{valueDisplay}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isArray(obj) && obj.length === 0) {
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>[]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(obj) && Object.keys(obj).length === 0) {
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{"{}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let isContainerAddedOrRemoved = false;
|
||||
|
||||
if (oldVersionObj && newVersionObj) {
|
||||
const oldValue = getNestedValue(oldVersionObj, path);
|
||||
const newValue = getNestedValue(newVersionObj, path);
|
||||
|
||||
if (oldValue == null || newValue == null) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (typeof oldValue !== typeof newValue) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (isArray(oldValue) !== isArray(newValue)) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (isObject(oldValue) !== isObject(newValue)) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
}
|
||||
} else {
|
||||
isContainerAddedOrRemoved = isContainerActuallyChanged(
|
||||
path,
|
||||
diffPaths,
|
||||
isOldVersion ? obj : oldVersionObj || null,
|
||||
isOldVersion ? newVersionObj || null : obj
|
||||
);
|
||||
}
|
||||
|
||||
if (isArray(obj)) {
|
||||
return (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>[</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obj.map((item: JsonValue, index: number) => {
|
||||
const itemPath = path ? `${path}[${index}]` : `[${index}]`;
|
||||
const isLast = index === obj.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${reactKey}-item-${index + 1}`}>
|
||||
{renderJsonWithDiffs(
|
||||
item,
|
||||
diffPaths,
|
||||
isOldVersion,
|
||||
itemPath,
|
||||
indentLevel + 1,
|
||||
undefined,
|
||||
isLast,
|
||||
excludedFieldsHighlight,
|
||||
oldVersionObj,
|
||||
newVersionObj
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(obj)) {
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
return (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"{"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keys.map((key, index) => {
|
||||
const keyPath = path ? `${path}.${key}` : key;
|
||||
const isLast = index === keys.length - 1;
|
||||
const propKey = `${reactKey}-prop-${key}`;
|
||||
|
||||
return (
|
||||
<div key={propKey}>
|
||||
{renderJsonWithDiffs(
|
||||
obj[key],
|
||||
diffPaths,
|
||||
isOldVersion,
|
||||
keyPath,
|
||||
indentLevel + 1,
|
||||
key,
|
||||
isLast,
|
||||
excludedFieldsHighlight,
|
||||
oldVersionObj,
|
||||
newVersionObj
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyDisplay}
|
||||
{String(obj)}
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatAddedJson = (json: JsonValue): JSX.Element => {
|
||||
const lines = JSON.stringify(json, null, 2).split("\n");
|
||||
return (
|
||||
<div className="font-mono text-sm text-green-300">
|
||||
{lines.map((line, lineIndex) => {
|
||||
const lineKey = `added-${line.slice(0, 30)}-${lineIndex}`;
|
||||
return (
|
||||
<div key={lineKey} className="flex">
|
||||
<div className="w-4 flex-shrink-0">+</div>
|
||||
<div>{line}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDeletedJson = (json: JsonValue): JSX.Element => {
|
||||
const lines = JSON.stringify(json, null, 2).split("\n");
|
||||
return (
|
||||
<div className="font-mono text-sm text-red-300">
|
||||
{lines.map((line, lineIndex) => {
|
||||
const lineKey = `deleted-${line.slice(0, 30)}-${lineIndex}`;
|
||||
return (
|
||||
<div key={lineKey} className="flex">
|
||||
<div className="w-4 flex-shrink-0">-</div>
|
||||
<div>{line}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const cleanVersionForComparison = (version: Version): JsonValue => {
|
||||
const { id, version: versionNumber, ...cleanVersion } = version;
|
||||
return cleanVersion;
|
||||
};
|
||||
|
||||
export const SecretVersionDiffView = ({
|
||||
item,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
showHeader = true,
|
||||
customHeader,
|
||||
excludedFieldsHighlight = ["metadata", "tags"]
|
||||
}: SecretVersionDiffViewProps) => {
|
||||
const oldContainerRef = useRef<HTMLDivElement>(null);
|
||||
const newContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(isCollapsed);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (onToggleCollapse && item.id) {
|
||||
onToggleCollapse(item.id);
|
||||
} else {
|
||||
setInternalCollapsed((prev) => !prev);
|
||||
}
|
||||
}, [onToggleCollapse, item.id]);
|
||||
|
||||
const collapsed = onToggleCollapse ? isCollapsed : internalCollapsed;
|
||||
|
||||
if (!item.versions || item.versions.length === 0) {
|
||||
return <div className="px-6 py-3 text-gray-400">No details available</div>;
|
||||
}
|
||||
|
||||
const sortedVersions = [...item.versions].sort((a, b) => b.version - a.version);
|
||||
let oldVersion = null;
|
||||
let newVersion = null;
|
||||
let oldVersionContent = null;
|
||||
let newVersionContent = null;
|
||||
let diffPaths = new Set<string>();
|
||||
|
||||
if (item.isUpdated && sortedVersions.length >= 2) {
|
||||
if (item.isRollback) {
|
||||
[oldVersion, newVersion] = sortedVersions;
|
||||
} else {
|
||||
[newVersion, oldVersion] = sortedVersions;
|
||||
}
|
||||
|
||||
const cleanOldVersion = cleanVersionForComparison(oldVersion);
|
||||
const cleanNewVersion = cleanVersionForComparison(newVersion);
|
||||
diffPaths = getDiffPaths(cleanOldVersion, cleanNewVersion);
|
||||
|
||||
if (diffPaths.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
oldVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanOldVersion,
|
||||
diffPaths,
|
||||
true,
|
||||
"",
|
||||
0,
|
||||
undefined,
|
||||
false,
|
||||
excludedFieldsHighlight,
|
||||
cleanOldVersion,
|
||||
cleanNewVersion
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
newVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanNewVersion,
|
||||
diffPaths,
|
||||
false,
|
||||
"",
|
||||
0,
|
||||
undefined,
|
||||
false,
|
||||
excludedFieldsHighlight,
|
||||
cleanOldVersion,
|
||||
cleanNewVersion
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (item.isAdded) {
|
||||
[newVersion] = sortedVersions;
|
||||
const cleanNewVersion = cleanVersionForComparison(newVersion);
|
||||
newVersionContent = formatAddedJson(cleanNewVersion);
|
||||
} else if (item.isDeleted) {
|
||||
[oldVersion] = sortedVersions;
|
||||
const cleanOldVersion = cleanVersionForComparison(oldVersion);
|
||||
oldVersionContent = formatDeletedJson(cleanOldVersion);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
if (customHeader) {
|
||||
return customHeader;
|
||||
}
|
||||
|
||||
const isSecret = item.type === "secret";
|
||||
const key = isSecret ? item.secretKey || "Unnamed Secret" : item.folderName || "Unnamed Folder";
|
||||
let textStyle = "text-white";
|
||||
let changeBadge = null;
|
||||
|
||||
if (item.isDeleted) {
|
||||
textStyle = "line-through text-red-300";
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Deleted
|
||||
</span>
|
||||
);
|
||||
} else if (item.isAdded) {
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Added
|
||||
</span>
|
||||
);
|
||||
} else if (item.isUpdated) {
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Updated
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-4 hover:bg-mineshaft-700"
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleToggle();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className={textStyle}>{key}</span>
|
||||
{changeBadge}
|
||||
</div>
|
||||
<FontAwesomeIcon icon={collapsed ? faChevronDown : faChevronUp} className="text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
|
||||
{showHeader && renderHeader()}
|
||||
|
||||
{!collapsed && (
|
||||
<div className="border-t border-mineshaft-700 bg-mineshaft-900 px-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
ref={oldContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
{oldVersionContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={newContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
{newVersionContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretVersionDiffView } from "./SecretVersionDiffView";
|
||||
@@ -0,0 +1 @@
|
||||
export { CommitDetailsPage } from "./CommitDetailsPage";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
|
||||
import { BreadcrumbTypes } from "@app/components/v2";
|
||||
|
||||
import { CommitDetailsPage } from "./CommitDetailsPage";
|
||||
|
||||
const CommitDetailsPageQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/"
|
||||
)({
|
||||
component: CommitDetailsPage,
|
||||
validateSearch: zodValidator(CommitDetailsPageQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
type: BreadcrumbTypes.Dropdown,
|
||||
label:
|
||||
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
|
||||
dropdownTitle: "Environments",
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
}
|
||||
})
|
||||
}))
|
||||
},
|
||||
...secretPathSegments.map((_, index) => ({
|
||||
type: BreadcrumbTypes.Component,
|
||||
component: () => (
|
||||
<SecretDashboardPathBreadcrumb
|
||||
secretPathSegments={secretPathSegments}
|
||||
selectedPathSegmentIndex={index}
|
||||
environmentSlug={params.environment}
|
||||
projectId={params.projectId}
|
||||
disableCopy
|
||||
/>
|
||||
)
|
||||
})),
|
||||
{
|
||||
label: "Commits",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: params.commitId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { CommitHistoryTab } from "./components/CommitHistoryTab";
|
||||
|
||||
export const CommitsPage = () => {
|
||||
const envSlug = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitsPage.id,
|
||||
select: (el) => el.environment
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
const folderId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitsPage.id,
|
||||
select: (el) => el.folderId
|
||||
});
|
||||
const routerQueryParams: { secretPath?: string } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.CommitsPage.id
|
||||
});
|
||||
|
||||
const secretPath = routerQueryParams?.secretPath || "/";
|
||||
|
||||
const handleSelectCommit = (commitId: string) => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId/$commitId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug,
|
||||
commitId
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-7xl justify-center bg-bunker-800 py-4 text-white">
|
||||
<div className="w-full max-w-[75vw]">
|
||||
<PageHeader
|
||||
title="Commits"
|
||||
description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states."
|
||||
/>
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionCommitsActions.Read}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
<CommitHistoryTab
|
||||
onSelectCommit={handleSelectCommit}
|
||||
projectId={currentWorkspace.id}
|
||||
environment={envSlug}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
faArrowDownWideShort,
|
||||
faArrowUpWideShort,
|
||||
faCopy,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { Button, Input, Spinner } from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { useGetFolderCommitHistory } from "@app/hooks/api/folderCommits";
|
||||
|
||||
interface CommitActorMetadata {
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
id: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
actorType: string;
|
||||
actorMetadata?: CommitActorMetadata;
|
||||
}
|
||||
|
||||
const formatTimeAgo = (timestamp: string): string => {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Commit Item component for displaying a single commit
|
||||
*/
|
||||
const CommitItem = ({
|
||||
commit,
|
||||
onSelectCommit
|
||||
}: {
|
||||
commit: Commit;
|
||||
onSelectCommit: (commitId: string, tab: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-b border-zinc-800 last:border-b-0">
|
||||
<div className="px-4 py-4 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between">
|
||||
<div className="w-5/6 flex-1">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="link"
|
||||
className="truncate text-left text-white hover:underline"
|
||||
isFullWidth
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCommit(commit.id, "tab-commit-details");
|
||||
}}
|
||||
>
|
||||
{commit.message}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-white-400 mt-2 flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="flex items-center text-mineshaft-300">
|
||||
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}
|
||||
<p className="ml-1 mr-1">committed</p>
|
||||
<time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex w-1/6 items-center justify-end sm:mt-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCommit(commit.id, "tab-commit-details");
|
||||
}}
|
||||
>
|
||||
<code className="text-white-400 px-3 py-1 font-mono text-sm">
|
||||
{commit.id?.substring(0, 11)}
|
||||
</code>
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={commit.id}
|
||||
name={commit.id}
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="text-mineshaft-400"
|
||||
icon={faCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Date Group component for displaying commits grouped by date
|
||||
*/
|
||||
const DateGroup = ({
|
||||
date,
|
||||
commits,
|
||||
onSelectCommit
|
||||
}: {
|
||||
date: string;
|
||||
commits: Commit[];
|
||||
onSelectCommit: (commitId: string, tab: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-8 last:mb-0 last:pb-2">
|
||||
<div className="mb-4 flex items-center">
|
||||
<div className="relative mr-3 flex h-6 w-6 items-center justify-center">
|
||||
<div className="z-10 h-3 w-3 rounded-full border-2 border-mineshaft-600 bg-bunker-800" />
|
||||
<div className="absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
<h2 className="text-sm text-white">Commits on {date}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute bottom-0 left-3 top-0 w-0.5 bg-mineshaft-600" />
|
||||
<div className="ml-10">
|
||||
{commits.map((commit) => (
|
||||
<div key={commit.id} className="relative mb-3 pb-1">
|
||||
<div className="overflow-hidden rounded-md border border-solid border-mineshaft-600">
|
||||
<CommitItem commit={commit} onSelectCommit={onSelectCommit} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommitHistoryTab = ({
|
||||
onSelectCommit,
|
||||
projectId,
|
||||
environment,
|
||||
secretPath
|
||||
}: {
|
||||
onSelectCommit: (commitId: string, tab: string) => void;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [allCommits, setAllCommits] = useState<Commit[]>([]);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const limit = 5;
|
||||
|
||||
// Debounce search term
|
||||
useEffect(() => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
const {
|
||||
data: response,
|
||||
isLoading,
|
||||
isFetching
|
||||
} = useGetFolderCommitHistory({
|
||||
workspaceId: projectId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
offset,
|
||||
limit,
|
||||
search: debouncedSearchTerm,
|
||||
sort: sortDirection
|
||||
});
|
||||
|
||||
const commits = response?.commits || [];
|
||||
const hasMore = response?.hasMore || false;
|
||||
|
||||
// Reset accumulated commits when search or sort changes
|
||||
useEffect(() => {
|
||||
setAllCommits([]);
|
||||
setOffset(0);
|
||||
}, [debouncedSearchTerm, sortDirection]);
|
||||
|
||||
// Accumulate commits instead of replacing them
|
||||
useEffect(() => {
|
||||
if (commits.length > 0) {
|
||||
if (offset === 0) {
|
||||
// First load or after search/sort change - replace all commits
|
||||
setAllCommits(commits);
|
||||
} else {
|
||||
// Subsequent loads - append new commits
|
||||
setAllCommits((prev) => [...prev, ...commits]);
|
||||
}
|
||||
}
|
||||
}, [commits, offset]);
|
||||
|
||||
const groupedCommits = useMemo(() => {
|
||||
return allCommits.reduce(
|
||||
(acc, commit) => {
|
||||
const date = format(new Date(commit.createdAt), "MMM d, yyyy");
|
||||
if (!acc[date]) {
|
||||
acc[date] = [];
|
||||
}
|
||||
acc[date].push(commit);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Commit[]>
|
||||
);
|
||||
}, [allCommits]);
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"));
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, []);
|
||||
|
||||
const loadMoreCommits = useCallback(() => {
|
||||
if (hasMore && !isFetching) {
|
||||
setOffset((prev) => prev + limit);
|
||||
}
|
||||
}, [hasMore, isFetching, limit]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:justify-end">
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<div className="relative flex-grow">
|
||||
<Input
|
||||
placeholder="Search commits..."
|
||||
className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
value={searchTerm}
|
||||
aria-label="Search commits"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
|
||||
<FontAwesomeIcon icon={faSearch} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="md"
|
||||
className="flex h-10 items-center justify-center gap-2 rounded-md bg-zinc-800 px-4 py-2 text-sm text-white transition-colors duration-200 hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
onClick={handleSort}
|
||||
aria-label={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={sortDirection === "desc" ? faArrowDownWideShort : faArrowUpWideShort}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && offset === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" aria-label="Loading commits" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.keys(groupedCommits).length > 0 ? (
|
||||
<>
|
||||
{Object.entries(groupedCommits).map(([date, dateCommits]) => (
|
||||
<DateGroup
|
||||
key={date}
|
||||
date={date}
|
||||
commits={dateCommits}
|
||||
onSelectCommit={onSelectCommit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
className="text-white-500 mb-3 text-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>No matching commits found. Try a different search term.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pb-2">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="md"
|
||||
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
onClick={loadMoreCommits}
|
||||
disabled={isFetching}
|
||||
aria-label="Load more commits"
|
||||
>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load more commits"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CommitHistoryTab } from "./CommitHistoryTab";
|
||||
@@ -0,0 +1 @@
|
||||
export { CommitsPage } from "./CommitsPage";
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
|
||||
import { BreadcrumbTypes } from "@app/components/v2";
|
||||
|
||||
import { CommitsPage } from "./CommitsPage";
|
||||
|
||||
const CommitsPageQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/"
|
||||
)({
|
||||
component: CommitsPage,
|
||||
validateSearch: zodValidator(CommitsPageQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
type: BreadcrumbTypes.Dropdown,
|
||||
label:
|
||||
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
|
||||
dropdownTitle: "Environments",
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
}
|
||||
})
|
||||
}))
|
||||
},
|
||||
...secretPathSegments.map((_, index) => ({
|
||||
type: BreadcrumbTypes.Component,
|
||||
component: () => (
|
||||
<SecretDashboardPathBreadcrumb
|
||||
secretPathSegments={secretPathSegments}
|
||||
selectedPathSegmentIndex={index}
|
||||
environmentSlug={params.environment}
|
||||
projectId={params.projectId}
|
||||
disableCopy
|
||||
/>
|
||||
)
|
||||
})),
|
||||
{
|
||||
label: "Commits",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
|
||||
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
|
||||
import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync";
|
||||
import {
|
||||
GitHubSyncScope,
|
||||
GitHubSyncVisibility
|
||||
@@ -47,7 +48,8 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
|
||||
break;
|
||||
case SecretSync.GCPSecretManager:
|
||||
primaryText = destinationConfig.projectId;
|
||||
secondaryText = "Global";
|
||||
secondaryText =
|
||||
destinationConfig.scope === GcpSyncScope.Global ? "Global" : destinationConfig.locationId;
|
||||
break;
|
||||
case SecretSync.AzureKeyVault:
|
||||
primaryText = destinationConfig.vaultBaseUrl;
|
||||
|
||||
+5
-4
@@ -224,8 +224,7 @@ export const SecretApprovalRequest = () => {
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser,
|
||||
isReplicated: isReplication
|
||||
committerUser
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
@@ -244,13 +243,15 @@ export const SecretApprovalRequest = () => {
|
||||
>
|
||||
<div className="mb-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{generateCommitText(commits)}
|
||||
{secretApproval.isReplicated
|
||||
? `${commits.length} secret pending import`
|
||||
: generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email}){isReplication && " via replication"}
|
||||
{committerUser?.email})
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+34
-45
@@ -12,7 +12,7 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Tag, Tooltip } from "@app/components/v2";
|
||||
import { SecretInput, Tag, Tooltip } from "@app/components/v2";
|
||||
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
export type Props = {
|
||||
@@ -86,10 +86,10 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
{op === CommitType.UPDATE || op === CommitType.DELETE ? (
|
||||
<div className="flex w-full cursor-default flex-col rounded-md border border-red-600/60 bg-red-600/10 p-4 xl:w-1/2">
|
||||
<div className="mb-4 flex flex-row justify-between">
|
||||
<span className="text-md font-medium">Legacy Secret</span>
|
||||
<span className="text-md font-medium">Previous Secret</span>
|
||||
<div className="rounded-full bg-red px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
|
||||
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
|
||||
Deprecated
|
||||
Previous
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
@@ -104,34 +104,29 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
Rotated Secret value will not be affected
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
|
||||
className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<SecretInput
|
||||
isReadOnly
|
||||
isVisible={isOldSecretValueVisible}
|
||||
value={secretVersion?.secretValue}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
|
||||
/>
|
||||
<div
|
||||
className={`flex font-mono ${isOldSecretValueVisible || !secretVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`}
|
||||
className="absolute right-1 top-1"
|
||||
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
|
||||
>
|
||||
{isOldSecretValueVisible
|
||||
? secretVersion?.secretValue || "EMPTY"
|
||||
: secretVersion?.secretValue
|
||||
? secretVersion?.secretValue?.split("").map(() => "•")
|
||||
: "EMPTY"}{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
|
||||
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
|
||||
/>
|
||||
</div>
|
||||
{secretVersion?.secretValue && (
|
||||
<div className="flex h-10 w-10 items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
|
||||
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
|
||||
<div className="text-sm">
|
||||
<div className="max-h-[5rem] overflow-y-auto text-sm">
|
||||
{secretVersion?.secretComment || (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
@@ -191,8 +186,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-md flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 text-mineshaft-300 xl:w-1/2">
|
||||
{" "}
|
||||
Secret not existent in the previous version.
|
||||
Secret did not exist in the previous version.
|
||||
</div>
|
||||
)}
|
||||
{op === CommitType.UPDATE || op === CommitType.CREATE ? (
|
||||
@@ -201,7 +195,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<span className="text-md font-medium">New Secret</span>
|
||||
<div className="rounded-full bg-green-600 px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
|
||||
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
|
||||
Current
|
||||
New
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
@@ -216,34 +210,29 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
Rotated Secret value will not be affected
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
|
||||
className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<SecretInput
|
||||
isReadOnly
|
||||
isVisible={isNewSecretValueVisible}
|
||||
value={newVersion?.secretValue}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
|
||||
/>
|
||||
<div
|
||||
className={`flex font-mono ${isNewSecretValueVisible || !newVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`}
|
||||
className="absolute right-1 top-1"
|
||||
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
|
||||
>
|
||||
{isNewSecretValueVisible
|
||||
? newVersion?.secretValue || "EMPTY"
|
||||
: newVersion?.secretValue
|
||||
? newVersion?.secretValue?.split("").map(() => "•")
|
||||
: "EMPTY"}{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
|
||||
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
|
||||
/>
|
||||
</div>
|
||||
{newVersion?.secretValue && (
|
||||
<div className="flex h-10 w-10 items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
|
||||
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
|
||||
<div className="text-sm">
|
||||
<div className="max-h-[5rem] overflow-y-auto text-sm">
|
||||
{newVersion?.secretComment || (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
@@ -302,7 +291,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
) : (
|
||||
<div className="text-md flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 text-mineshaft-300 xl:w-1/2">
|
||||
{" "}
|
||||
Secret not existent in the new version.
|
||||
Secret did not exist in the previous version.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+107
-30
@@ -30,19 +30,24 @@ import {
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { useUser, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetSecretApprovalRequestDetails,
|
||||
useGetSecretImports,
|
||||
useUpdateSecretApprovalReviewStatus
|
||||
} from "@app/hooks/api";
|
||||
import { ApprovalStatus, CommitType } from "@app/hooks/api/types";
|
||||
import { formatReservedPaths } from "@app/lib/fn/string";
|
||||
import { formatReservedPaths, parsePathFromReplicatedPath } from "@app/lib/fn/string";
|
||||
|
||||
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
|
||||
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
|
||||
|
||||
export const generateCommitText = (commits: { op: CommitType }[] = []) => {
|
||||
export const generateCommitText = (commits: { op: CommitType }[] = [], isReplicated = false) => {
|
||||
if (isReplicated) {
|
||||
return <span>{commits.length} secret pending import</span>;
|
||||
}
|
||||
|
||||
const score: Record<string, number> = {};
|
||||
commits.forEach(({ op }) => {
|
||||
score[op] = (score?.[op] || 0) + 1;
|
||||
@@ -74,7 +79,6 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => {
|
||||
<span style={{ color: "#F83030" }}> deleted</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -105,6 +109,7 @@ export const SecretApprovalRequestChanges = ({
|
||||
workspaceId
|
||||
}: Props) => {
|
||||
const { user: userSession } = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
data: secretApprovalRequestDetails,
|
||||
isSuccess: isSecretApprovalRequestSuccess,
|
||||
@@ -112,6 +117,20 @@ export const SecretApprovalRequestChanges = ({
|
||||
} = useGetSecretApprovalRequestDetails({
|
||||
id: approvalRequestId
|
||||
});
|
||||
const approvalSecretPath = parsePathFromReplicatedPath(
|
||||
secretApprovalRequestDetails?.secretPath || ""
|
||||
);
|
||||
const { data: secretImports } = useGetSecretImports({
|
||||
environment: secretApprovalRequestDetails?.environment || "",
|
||||
projectId: currentWorkspace.id,
|
||||
path: approvalSecretPath
|
||||
});
|
||||
|
||||
const replicatedImport = secretApprovalRequestDetails?.isReplicated
|
||||
? secretImports?.find(
|
||||
(el) => secretApprovalRequestDetails?.secretPath?.includes(el.id) && el.isReplication
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
mutateAsync: updateSecretApprovalRequestStatus,
|
||||
@@ -226,34 +245,16 @@ export const SecretApprovalRequestChanges = ({
|
||||
: secretApprovalRequestDetails.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="text-lg">
|
||||
{generateCommitText(secretApprovalRequestDetails.commits)}
|
||||
{secretApprovalRequestDetails.isReplicated && (
|
||||
<span className="text-sm text-bunker-300"> (replication)</span>
|
||||
<div className="flex-grow flex-col">
|
||||
<div className="text-xl">
|
||||
{generateCommitText(
|
||||
secretApprovalRequestDetails.commits,
|
||||
secretApprovalRequestDetails.isReplicated
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-bunker-300">
|
||||
<p className="inline">
|
||||
{secretApprovalRequestDetails?.committerUser?.firstName || ""}
|
||||
{secretApprovalRequestDetails?.committerUser?.lastName || ""} (
|
||||
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
|
||||
{secretApprovalRequestDetails.commits.length} secret values in
|
||||
</p>
|
||||
<p className="mx-1 inline rounded bg-primary-600/40 px-1 py-1 text-primary-300">
|
||||
{secretApprovalRequestDetails.environment}
|
||||
</p>
|
||||
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
</p>
|
||||
<p
|
||||
className="cursor-default truncate pb-0.5 pl-2 text-sm"
|
||||
style={{ maxWidth: "10rem" }}
|
||||
>
|
||||
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
By {secretApprovalRequestDetails?.committerUser?.firstName} (
|
||||
{secretApprovalRequestDetails?.committerUser?.email})
|
||||
</div>
|
||||
</div>
|
||||
{!hasMerged &&
|
||||
@@ -367,6 +368,82 @@ export const SecretApprovalRequestChanges = ({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
|
||||
<div>
|
||||
{secretApprovalRequestDetails.isReplicated ? (
|
||||
<div className="text-sm text-bunker-300">
|
||||
A secret import in
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{secretApprovalRequestDetails?.environment}
|
||||
</p>
|
||||
<div className="mr-2 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={approvalSecretPath}>
|
||||
<p
|
||||
className="cursor-default truncate pb-0.5 pl-2 text-sm"
|
||||
style={{ maxWidth: "15rem" }}
|
||||
>
|
||||
{approvalSecretPath}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
has pending changes to be accepted from its source at{" "}
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{replicatedImport?.importEnv?.slug}
|
||||
</p>
|
||||
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={replicatedImport?.importPath}>
|
||||
<p
|
||||
className="cursor-default truncate pb-0.5 pl-2 text-sm"
|
||||
style={{ maxWidth: "15rem" }}
|
||||
>
|
||||
{replicatedImport?.importPath}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
. Approving these changes will add them to that import.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-bunker-300">
|
||||
<p className="inline">Secret(s) in</p>
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{secretApprovalRequestDetails?.environment}
|
||||
</p>
|
||||
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
|
||||
<p
|
||||
className="cursor-default truncate pb-0.5 pl-2 text-sm"
|
||||
style={{ maxWidth: "20rem" }}
|
||||
>
|
||||
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="inline">
|
||||
have pending changes. Approving these changes will add them to that environment
|
||||
and path.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{secretApprovalRequestDetails.commits.map(
|
||||
({ op, secretVersion, secret, ...newVersion }, index) => (
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
|
||||
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
@@ -277,6 +278,17 @@ const Page = () => {
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const {
|
||||
data: { count: folderCommitsCount, folderId } = { count: 0, folderId: "" },
|
||||
isPending: isFolderCommitsCountLoading,
|
||||
isFetching: isFolderCommitsCountFetching
|
||||
} = useGetFolderCommitsCount({
|
||||
directory: secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
isPaused: !canDoReadRollback
|
||||
});
|
||||
|
||||
const {
|
||||
data: snapshotCount,
|
||||
isPending: isSnapshotCountLoading,
|
||||
@@ -288,6 +300,39 @@ const Page = () => {
|
||||
isPaused: !canDoReadRollback
|
||||
});
|
||||
|
||||
const isPITEnabled = !currentWorkspace?.showSnapshotsLegacy;
|
||||
|
||||
const changesCount = useMemo(() => {
|
||||
return isPITEnabled ? folderCommitsCount : snapshotCount;
|
||||
}, [folderCommitsCount, snapshotCount]);
|
||||
|
||||
const isChangesCountPending = useMemo(() => {
|
||||
return isPITEnabled ? isFolderCommitsCountLoading || isSnapshotCountLoading : false;
|
||||
}, [isFolderCommitsCountLoading, isSnapshotCountLoading]);
|
||||
|
||||
const isChangesCountFetching = useMemo(() => {
|
||||
return isPITEnabled ? isFolderCommitsCountFetching || isSnapshotCountFetching : false;
|
||||
}, [isFolderCommitsCountFetching, isSnapshotCountFetching]);
|
||||
|
||||
const handleOnClickRollbackMode = () => {
|
||||
if (isPITEnabled) {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
|
||||
params: {
|
||||
projectId: workspaceId,
|
||||
folderId,
|
||||
environment
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
} else {
|
||||
handlePopUpToggle("snapshots", true);
|
||||
}
|
||||
};
|
||||
|
||||
const noAccessSecretCount = Math.max(
|
||||
(page * perPage > totalCount ? totalCount % perPage : perPage) -
|
||||
(imports?.length || 0) -
|
||||
@@ -448,13 +493,14 @@ const Page = () => {
|
||||
onVisibilityToggle={handleToggleVisibility}
|
||||
onSearchChange={handleSearchChange}
|
||||
onToggleTagFilter={handleTagToggle}
|
||||
snapshotCount={snapshotCount || 0}
|
||||
isSnapshotCountLoading={isSnapshotCountLoading && isSnapshotCountFetching}
|
||||
snapshotCount={changesCount || 0}
|
||||
isSnapshotCountLoading={isChangesCountPending && isChangesCountFetching}
|
||||
onToggleRowType={handleToggleRowType}
|
||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||
onClickRollbackMode={handleOnClickRollbackMode}
|
||||
protectedBranchPolicyName={boardPolicy?.name}
|
||||
importedBy={importedBy}
|
||||
usedBySecretSyncs={usedBySecretSyncs}
|
||||
isPITEnabled={isPITEnabled}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
|
||||
<div className="flex flex-col" id="dashboard">
|
||||
@@ -618,7 +664,7 @@ const Page = () => {
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
folders={folders}
|
||||
snapshotCount={snapshotCount}
|
||||
snapshotCount={changesCount}
|
||||
onGoBack={handleResetSnapshot}
|
||||
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
|
||||
+9
-4
@@ -56,7 +56,10 @@ import {
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSecretRotationActions
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
@@ -123,6 +126,7 @@ type Props = {
|
||||
isImported: boolean;
|
||||
}[];
|
||||
}[];
|
||||
isPITEnabled: boolean;
|
||||
};
|
||||
|
||||
export const ActionBar = ({
|
||||
@@ -142,6 +146,7 @@ export const ActionBar = ({
|
||||
onToggleRowType,
|
||||
protectedBranchPolicyName,
|
||||
importedBy,
|
||||
isPITEnabled = false,
|
||||
usedBySecretSyncs
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
@@ -777,8 +782,8 @@ export const ActionBar = ({
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.SecretRollback}
|
||||
I={isPITEnabled ? ProjectPermissionCommitsActions.Read : ProjectPermissionActions.Read}
|
||||
a={isPITEnabled ? ProjectPermissionSub.Commits : ProjectPermissionSub.SecretRollback}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
@@ -796,7 +801,7 @@ export const ActionBar = ({
|
||||
className="h-10"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{`${snapshotCount} ${snapshotCount === 1 ? "Snapshot" : "Snapshots"}`}
|
||||
{`${snapshotCount} ${isPITEnabled ? "Commit" : "Snapshot"}${snapshotCount === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
+119
-46
@@ -5,22 +5,46 @@ import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import {
|
||||
DynamicSecretAwsIamAuth,
|
||||
DynamicSecretProviders
|
||||
} from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
accessKey: z.string().trim().min(1),
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
region: z.string().trim().min(1),
|
||||
awsPath: z.string().trim().optional(),
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
}),
|
||||
provider: z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AccessKey),
|
||||
accessKey: z.string().trim().min(1),
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
region: z.string().trim().min(1),
|
||||
awsPath: z.string().trim().optional(),
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
roleArn: z.string().trim().min(1),
|
||||
region: z.string().trim().min(1),
|
||||
awsPath: z.string().trim().optional(),
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
@@ -67,16 +91,21 @@ export const AwsIamInputForm = ({
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
handleSubmit,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
environment: isSingleEnvironmentMode ? environments[0] : undefined,
|
||||
usernameTemplate: "{{randomUsername}}"
|
||||
usernameTemplate: "{{randomUsername}}",
|
||||
provider: {
|
||||
method: DynamicSecretAwsIamAuth.AssumeRole
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
const isAccessKeyMethod = watch("provider.method") === DynamicSecretAwsIamAuth.AccessKey;
|
||||
|
||||
const handleCreateDynamicSecret = async ({
|
||||
name,
|
||||
@@ -127,7 +156,7 @@ export const AwsIamInputForm = ({
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-postgres" />
|
||||
<Input {...field} placeholder="dynamic-aws-iam" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -170,38 +199,82 @@ export const AwsIamInputForm = ({
|
||||
Configuration
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.accessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Access Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
<Controller
|
||||
name="provider.method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.secretAccessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Secret Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SelectItem value={DynamicSecretAwsIamAuth.AssumeRole}>
|
||||
Assume Role (Recommended)
|
||||
</SelectItem>
|
||||
<SelectItem value={DynamicSecretAwsIamAuth.AccessKey}>Access Key</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{isAccessKeyMethod ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.accessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Access Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.secretAccessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Secret Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.roleArn"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assume Role ARN"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+309
-124
@@ -29,55 +29,124 @@ import {
|
||||
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
|
||||
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import {
|
||||
DynamicSecretProviders,
|
||||
KubernetesDynamicSecretCredentialType
|
||||
} from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
enum CredentialType {
|
||||
Dynamic = "dynamic",
|
||||
Static = "static"
|
||||
enum RoleType {
|
||||
ClusterRole = "cluster-role",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
export enum AuthMethod {
|
||||
Api = "api",
|
||||
Gateway = "gateway"
|
||||
}
|
||||
|
||||
const credentialTypes = [
|
||||
{
|
||||
label: "Static",
|
||||
value: CredentialType.Static
|
||||
value: KubernetesDynamicSecretCredentialType.Static
|
||||
},
|
||||
{
|
||||
label: "Dynamic",
|
||||
value: KubernetesDynamicSecretCredentialType.Dynamic
|
||||
}
|
||||
] as const;
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
url: z.string().url().trim().min(1),
|
||||
clusterToken: z.string().trim().min(1),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(CredentialType.Static),
|
||||
serviceAccountName: z.string().trim().min(1),
|
||||
namespace: z.string().trim().min(1),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1))
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const formSchema = z
|
||||
.object({
|
||||
provider: z.discriminatedUnion("credentialType", [
|
||||
z.object({
|
||||
url: z.string().trim().optional(),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
|
||||
serviceAccountName: z.string().trim().min(1),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(
|
||||
(val) => !val.includes(","),
|
||||
"Namespace must be a single value, not a comma-separated list"
|
||||
),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
|
||||
}),
|
||||
z.object({
|
||||
url: z.string().trim().optional(),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => {
|
||||
const namespaces = val.split(",").map((ns) => ns.trim());
|
||||
return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0);
|
||||
}, "Must be a valid comma-separated list of namespace values"),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
roleType: z.nativeEnum(RoleType),
|
||||
role: z.string().trim().min(1),
|
||||
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: slugSchema(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: slugSchema(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
usernameTemplate: z.string().trim().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.provider.authMethod === AuthMethod.Gateway && !data.provider.gatewayId) {
|
||||
ctx.addIssue({
|
||||
path: ["provider.gatewayId"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Gateway, a gateway must be selected"
|
||||
});
|
||||
}
|
||||
if (data.provider.authMethod === AuthMethod.Api) {
|
||||
if (!data.provider.clusterToken) {
|
||||
ctx.addIssue({
|
||||
path: ["provider.clusterToken"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster token must be provided"
|
||||
});
|
||||
}
|
||||
if (!data.provider.url) {
|
||||
ctx.addIssue({
|
||||
path: ["provider.url"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster URL must be provided"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema> & FieldValues;
|
||||
|
||||
@@ -113,10 +182,11 @@ export const KubernetesInputForm = ({
|
||||
sslEnabled: false,
|
||||
serviceAccountName: "",
|
||||
namespace: "",
|
||||
credentialType: CredentialType.Static,
|
||||
credentialType: KubernetesDynamicSecretCredentialType.Static,
|
||||
gatewayId: undefined,
|
||||
audiences: []
|
||||
},
|
||||
audiences: [],
|
||||
authMethod: AuthMethod.Api
|
||||
} as const,
|
||||
environment: isSingleEnvironmentMode ? environments[0] : undefined
|
||||
}
|
||||
});
|
||||
@@ -130,20 +200,32 @@ export const KubernetesInputForm = ({
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const sslEnabled = watch("provider.sslEnabled");
|
||||
const credentialType = watch("provider.credentialType");
|
||||
const authMethod = watch("provider.authMethod");
|
||||
|
||||
const handleCreateDynamicSecret = async (formData: TForm) => {
|
||||
const { provider, ...rest } = formData;
|
||||
const { provider, usernameTemplate, ...rest } = formData;
|
||||
// wait till previous request is finished
|
||||
if (createDynamicSecret.isPending) return;
|
||||
|
||||
try {
|
||||
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider },
|
||||
provider: {
|
||||
type: DynamicSecretProviders.Kubernetes,
|
||||
inputs: {
|
||||
...provider,
|
||||
url: provider.url || undefined
|
||||
}
|
||||
},
|
||||
maxTTL: rest.maxTTL,
|
||||
name: rest.name,
|
||||
path: secretPath,
|
||||
defaultTTL: rest.defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: rest.environment.slug
|
||||
environmentSlug: rest.environment.slug,
|
||||
usernameTemplate:
|
||||
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
|
||||
});
|
||||
|
||||
onCompleted();
|
||||
@@ -282,81 +364,108 @@ export const KubernetesInputForm = ({
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.url"
|
||||
name="provider.authMethod"
|
||||
defaultValue={AuthMethod.Api}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster URL"
|
||||
label="Auth Method"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
tooltipText="Select the method of authentication. Token (API) uses a direct API token, while Gateway uses the service account of a Gateway deployed in a Kubernetes cluster to generate the service account token."
|
||||
>
|
||||
<Input {...field} />
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
<SelectItem value={AuthMethod.Api}>Token (API)</SelectItem>
|
||||
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-2 flex items-center">
|
||||
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
|
||||
Enable SSL
|
||||
<Tooltip
|
||||
className="ml-1 max-w-md"
|
||||
content={
|
||||
<span>
|
||||
If enabled, you can optionally provide a custom CA certificate. Leave
|
||||
blank to use the system/public CA.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Controller
|
||||
name="provider.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Enable SSL"
|
||||
{authMethod === AuthMethod.Api && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.url"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster URL"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-2 flex items-center">
|
||||
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
|
||||
Enable SSL
|
||||
<Tooltip
|
||||
className="ml-1 max-w-md"
|
||||
content={
|
||||
<span>
|
||||
If enabled, you can optionally provide a custom CA certificate. Leave
|
||||
blank to use the system/public CA.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Controller
|
||||
name="provider.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Enable SSL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ..."
|
||||
isDisabled={!sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{authMethod === AuthMethod.Api && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.clusterToken"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster Token"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ..."
|
||||
isDisabled={!sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.clusterToken"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster Token"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.credentialType"
|
||||
@@ -366,6 +475,7 @@ export const KubernetesInputForm = ({
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
tooltipText="Choose 'Static' to generate service account tokens for a predefined service account. Choose 'Dynamic' to create a temporary service account, assign it to a defined role/cluster-role, and generate the service account token. Only 'Dynamic' supports role assignment."
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
@@ -373,12 +483,9 @@ export const KubernetesInputForm = ({
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{credentialTypes.map((credentialType) => (
|
||||
<SelectItem
|
||||
value={credentialType.value}
|
||||
key={`credential-type-${credentialType.value}`}
|
||||
>
|
||||
{credentialType.label}
|
||||
{credentialTypes.map((ct) => (
|
||||
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
|
||||
{ct.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -386,28 +493,56 @@ export const KubernetesInputForm = ({
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.serviceAccountName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Name"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Static && (
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.serviceAccountName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Name"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
defaultValue="{{randomUsername}}"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || undefined}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.namespace"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
label={
|
||||
credentialType === KubernetesDynamicSecretCredentialType.Static
|
||||
? "Namespace"
|
||||
: "Allowed Namespace(s)"
|
||||
}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
@@ -417,6 +552,56 @@ export const KubernetesInputForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.roleType"
|
||||
defaultValue={RoleType.ClusterRole}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role Type"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
<SelectItem
|
||||
value={RoleType.ClusterRole}
|
||||
key={`role-type-${RoleType.ClusterRole}`}
|
||||
>
|
||||
Cluster Role
|
||||
</SelectItem>
|
||||
<SelectItem value={RoleType.Role} key={`role-type-${RoleType.Role}`}>
|
||||
Role
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.role"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 w-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+152
-1
@@ -353,12 +353,149 @@ const renderOutputForm = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const kubernetesFormSchema = z.object({
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.optional(),
|
||||
namespace: z.string().optional()
|
||||
});
|
||||
|
||||
type TKubernetesForm = z.infer<typeof kubernetesFormSchema>;
|
||||
|
||||
export const CreateKubernetesDynamicSecretLease = ({
|
||||
onClose,
|
||||
projectSlug,
|
||||
dynamicSecretName,
|
||||
provider,
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TKubernetesForm>({
|
||||
resolver: zodResolver(kubernetesFormSchema),
|
||||
defaultValues: {
|
||||
ttl: "1h"
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecretLease = useCreateDynamicSecretLease();
|
||||
|
||||
const handleDynamicSecretLeaseCreate = async ({ ttl, namespace }: TKubernetesForm) => {
|
||||
if (createDynamicSecretLease.isPending) return;
|
||||
try {
|
||||
await createDynamicSecretLease.mutateAsync({
|
||||
environmentSlug: environment,
|
||||
projectSlug,
|
||||
path: secretPath,
|
||||
ttl,
|
||||
dynamicSecretName,
|
||||
config: {
|
||||
namespace: namespace || undefined
|
||||
},
|
||||
provider
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully leased dynamic secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to lease dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaseRegeneration = async (data: { ttl?: string }) => {
|
||||
handleDynamicSecretLeaseCreate(data);
|
||||
};
|
||||
|
||||
const isOutputMode = Boolean(createDynamicSecretLease?.data);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnimatePresence>
|
||||
{!isOutputMode && (
|
||||
<motion.div
|
||||
key="lease-input"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="namespace"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="The Kubernetes namespace to lease the dynamic secret to. If not specified, the first namespace defined in the configuration will be used."
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ttl"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
{isOutputMode && (
|
||||
<motion.div
|
||||
key="lease-output"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
{renderOutputForm(
|
||||
provider,
|
||||
createDynamicSecretLease.data?.data,
|
||||
handleLeaseRegeneration
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.optional()
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
@@ -404,7 +541,8 @@ export const CreateDynamicSecretLease = ({
|
||||
projectSlug,
|
||||
path: secretPath,
|
||||
ttl,
|
||||
dynamicSecretName
|
||||
dynamicSecretName,
|
||||
provider
|
||||
});
|
||||
|
||||
createNotification({
|
||||
@@ -433,6 +571,19 @@ export const CreateDynamicSecretLease = ({
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
if (provider === DynamicSecretProviders.Kubernetes) {
|
||||
return (
|
||||
<CreateKubernetesDynamicSecretLease
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
dynamicSecretName={dynamicSecretName}
|
||||
provider={provider}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isOutputMode = Boolean(createDynamicSecretLease?.data);
|
||||
|
||||
if (isPreloading) {
|
||||
|
||||
+93
-36
@@ -5,14 +5,15 @@ import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { Button, FormControl, Input, Select, SelectItem, TextArea } from "@app/components/v2";
|
||||
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { DynamicSecretAwsIamAuth, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z
|
||||
.object({
|
||||
inputs: z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AccessKey),
|
||||
accessKey: z.string().trim().min(1),
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
region: z.string().trim().min(1),
|
||||
@@ -21,8 +22,18 @@ const formSchema = z.object({
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
roleArn: z.string().trim().min(1),
|
||||
region: z.string().trim().min(1),
|
||||
awsPath: z.string().trim().optional(),
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
})
|
||||
.partial(),
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
@@ -66,6 +77,7 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
@@ -80,6 +92,7 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
}
|
||||
}
|
||||
});
|
||||
const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey;
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
@@ -173,38 +186,82 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
<div>
|
||||
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.accessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Access Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
<Controller
|
||||
name="inputs.method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.secretAccessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Secret Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SelectItem value={DynamicSecretAwsIamAuth.AssumeRole}>
|
||||
Assume Role (Recommended)
|
||||
</SelectItem>
|
||||
<SelectItem value={DynamicSecretAwsIamAuth.AccessKey}>Access Key</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{isAccessKeyMethod ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.accessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Access Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.secretAccessKey"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="AWS Secret Key"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.roleArn"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assume Role ARN"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+307
-119
@@ -28,53 +28,122 @@ import {
|
||||
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
|
||||
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import {
|
||||
KubernetesDynamicSecretCredentialType,
|
||||
TDynamicSecret
|
||||
} from "@app/hooks/api/dynamicSecret/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
enum CredentialType {
|
||||
Dynamic = "dynamic",
|
||||
Static = "static"
|
||||
enum RoleType {
|
||||
ClusterRole = "cluster-role",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
enum AuthMethod {
|
||||
Api = "api",
|
||||
Gateway = "gateway"
|
||||
}
|
||||
|
||||
const credentialTypes = [
|
||||
{
|
||||
label: "Static",
|
||||
value: CredentialType.Static
|
||||
value: KubernetesDynamicSecretCredentialType.Static
|
||||
},
|
||||
{
|
||||
label: "Dynamic",
|
||||
value: KubernetesDynamicSecretCredentialType.Dynamic
|
||||
}
|
||||
] as const;
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z.object({
|
||||
url: z.string().url().trim().min(1),
|
||||
clusterToken: z.string().trim().min(1),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(CredentialType.Static),
|
||||
serviceAccountName: z.string().trim().min(1),
|
||||
namespace: z.string().trim().min(1),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1))
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const formSchema = z
|
||||
.object({
|
||||
inputs: z.discriminatedUnion("credentialType", [
|
||||
z.object({
|
||||
url: z.string().trim().optional(),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
|
||||
serviceAccountName: z.string().trim().min(1),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(
|
||||
(val) => !val.includes(","),
|
||||
"Namespace must be a single value, not a comma-separated list"
|
||||
),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
|
||||
}),
|
||||
z.object({
|
||||
url: z.string().trim().optional(),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => {
|
||||
const namespaces = val.split(",").map((ns) => ns.trim());
|
||||
return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0);
|
||||
}, "Must be a valid comma-separated list of namespace values"),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
roleType: z.nativeEnum(RoleType),
|
||||
role: z.string().trim().min(1),
|
||||
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
newName: slugSchema().optional()
|
||||
});
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
newName: slugSchema().optional(),
|
||||
usernameTemplate: z.string().trim().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.inputs.authMethod === AuthMethod.Gateway && !data.inputs.gatewayId) {
|
||||
ctx.addIssue({
|
||||
path: ["inputs.gatewayId"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Gateway, a gateway must be selected"
|
||||
});
|
||||
}
|
||||
if (data.inputs.authMethod === AuthMethod.Api) {
|
||||
if (!data.inputs.clusterToken) {
|
||||
ctx.addIssue({
|
||||
path: ["inputs.clusterToken"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster token must be provided"
|
||||
});
|
||||
}
|
||||
if (!data.inputs.url) {
|
||||
ctx.addIssue({
|
||||
path: ["inputs.url"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster URL must be provided"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema> & FieldValues;
|
||||
|
||||
@@ -103,6 +172,7 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
values: {
|
||||
newName: dynamicSecret.name,
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
inputs: dynamicSecret.inputs as TForm["inputs"]
|
||||
}
|
||||
@@ -110,17 +180,20 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "inputs.audiences" as const
|
||||
name: "inputs.audiences"
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const sslEnabled = watch("inputs.sslEnabled");
|
||||
const credentialType = watch("inputs.credentialType");
|
||||
const authMethod = watch("inputs.authMethod");
|
||||
|
||||
const handleUpdateDynamicSecret = async (formData: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
const isDefaultUsernameTemplate = formData.usernameTemplate === "{{randomUsername}}";
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
@@ -128,12 +201,20 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
inputs: formData.inputs,
|
||||
inputs: {
|
||||
...formData.inputs,
|
||||
url: formData.inputs.url || undefined
|
||||
},
|
||||
newName: formData.newName === dynamicSecret.name ? undefined : formData.newName,
|
||||
defaultTTL: formData.defaultTTL,
|
||||
maxTTL: formData.maxTTL
|
||||
maxTTL: formData.maxTTL,
|
||||
usernameTemplate:
|
||||
!formData.usernameTemplate || isDefaultUsernameTemplate
|
||||
? null
|
||||
: formData.usernameTemplate
|
||||
}
|
||||
});
|
||||
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
@@ -275,81 +356,110 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.url"
|
||||
name="inputs.authMethod"
|
||||
defaultValue={AuthMethod.Api}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster URL"
|
||||
label="Auth Method"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
tooltipText="Select the method of authentication. Token (API) uses a direct API token, while Gateway uses the service account of a Gateway deployed in a Kubernetes cluster to generate the service account token."
|
||||
>
|
||||
<Input {...field} />
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
<SelectItem value={AuthMethod.Api}>Token (API)</SelectItem>
|
||||
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{authMethod === AuthMethod.Api && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.url"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster URL"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-2 flex items-center">
|
||||
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
|
||||
Enable SSL
|
||||
<Tooltip
|
||||
className="ml-1 max-w-md"
|
||||
content={
|
||||
<span>
|
||||
If enabled, you can optionally provide a custom CA certificate. Leave
|
||||
blank to use the system/public CA.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Controller
|
||||
name="inputs.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Enable SSL"
|
||||
<div className="mb-2 flex items-center">
|
||||
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
|
||||
Enable SSL
|
||||
<Tooltip
|
||||
className="ml-1 max-w-md"
|
||||
content={
|
||||
<span>
|
||||
If enabled, you can optionally provide a custom CA certificate.
|
||||
Leave blank to use the system/public CA.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Controller
|
||||
name="inputs.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Enable SSL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ..."
|
||||
isDisabled={!sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{authMethod === AuthMethod.Api && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.clusterToken"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster Token"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ..."
|
||||
isDisabled={!sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.clusterToken"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Cluster Token"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.credentialType"
|
||||
@@ -359,6 +469,7 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
tooltipText="Choose 'Static' to generate service account tokens for a predefined service account. Choose 'Dynamic' to create a temporary service account, assign it to a defined role/cluster-role, and generate the service account token. Only 'Dynamic' supports role assignment."
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
@@ -366,12 +477,9 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{credentialTypes.map((credentialType) => (
|
||||
<SelectItem
|
||||
value={credentialType.value}
|
||||
key={`credential-type-${credentialType.value}`}
|
||||
>
|
||||
{credentialType.label}
|
||||
{credentialTypes.map((ct) => (
|
||||
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
|
||||
{ct.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -379,28 +487,55 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.serviceAccountName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Name"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Static && (
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.serviceAccountName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Name"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || undefined}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.namespace"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
label={
|
||||
credentialType === KubernetesDynamicSecretCredentialType.Static
|
||||
? "Namespace"
|
||||
: "Allowed Namespace(s)"
|
||||
}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
@@ -410,6 +545,59 @@ export const EditDynamicSecretKubernetesForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.roleType"
|
||||
defaultValue={RoleType.ClusterRole}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role Type"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
<SelectItem
|
||||
value={RoleType.ClusterRole}
|
||||
key={`role-type-${RoleType.ClusterRole}`}
|
||||
>
|
||||
Cluster Role
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={RoleType.Role}
|
||||
key={`role-type-${RoleType.Role}`}
|
||||
>
|
||||
Role
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.role"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-1/2">
|
||||
|
||||
+15
@@ -3,6 +3,7 @@ import { InfiniteData } from "@tanstack/react-query";
|
||||
import { formatDistance } from "date-fns";
|
||||
|
||||
import { Button, Drawer, DrawerContent } from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { TSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
|
||||
|
||||
type Props = {
|
||||
@@ -39,6 +40,20 @@ export const PitDrawer = ({
|
||||
subTitle="Note: This will recover secrets for all environments in this project"
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<NoticeBannerV2 title="Snapshots are being deprecated" className="mb-2">
|
||||
<p className="my-1 text-sm text-mineshaft-300">
|
||||
Snapshots are being deprecated in favor of{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://infisical.com/docs/documentation/platform/pit-recovery"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
|
||||
>
|
||||
Commits
|
||||
</a>
|
||||
. This feature will be officially removed in November 2025.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
{secretSnaphots?.pages?.map((group, i) => (
|
||||
<Fragment key={`snapshot-item-${i + 1}`}>
|
||||
{group.map(({ id, createdAt }, index) => (
|
||||
|
||||
+13
@@ -9,6 +9,7 @@ import { usePopUp } from "@app/hooks";
|
||||
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import { commitKeys } from "@app/hooks/api/folderCommits/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
@@ -264,6 +265,12 @@ export const SecretListView = ({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ workspaceId })
|
||||
});
|
||||
@@ -315,6 +322,12 @@ export const SecretListView = ({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ workspaceId })
|
||||
});
|
||||
|
||||
+30
-12
@@ -12,6 +12,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, ContentLoader, Input, Tag, Tooltip } from "@app/components/v2";
|
||||
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useGetSnapshotSecrets, usePerformSecretRollback } from "@app/hooks/api";
|
||||
import { SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
|
||||
@@ -60,6 +61,7 @@ export const SnapshotView = ({
|
||||
|
||||
const rollingFolder = snapshotData?.folders || [];
|
||||
const rollingSecrets = snapshotData?.secrets || [];
|
||||
const isAllowedRollback = false;
|
||||
|
||||
const folderDiffView = useMemo(() => {
|
||||
const folderGroupById = folders.reduce<Record<string, TSecretFolder>>(
|
||||
@@ -157,6 +159,20 @@ export const SnapshotView = ({
|
||||
<h6 className="text-2xl">Snapshot</h6>
|
||||
<Tag colorSchema="green">{new Date(snapshotData?.createdAt || "").toLocaleString()}</Tag>
|
||||
</div>
|
||||
<NoticeBannerV2 title="Snapshots are being deprecated" className="my-2">
|
||||
<p className="my-1 text-sm text-mineshaft-300">
|
||||
Snapshots are being deprecated in favor of{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://infisical.com/docs/documentation/platform/pit-recovery"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
|
||||
>
|
||||
Commits
|
||||
</a>
|
||||
. This feature will be officially removed in November 2025.
|
||||
</p>
|
||||
</NoticeBannerV2>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<div className="w-2/5">
|
||||
<Input
|
||||
@@ -168,17 +184,19 @@ export const SnapshotView = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={onClickListSnapshot}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
className="h-10"
|
||||
isDisabled={isRollingBack}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
</div>
|
||||
{isAllowedRollback && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={onClickListSnapshot}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
className="h-10"
|
||||
isDisabled={isRollingBack}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
onClick={onGoBack}
|
||||
@@ -198,7 +216,7 @@ export const SnapshotView = ({
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={handleClickRollback}
|
||||
isDisabled={isRollingBack || !isAllowed}
|
||||
isDisabled={isRollingBack || !isAllowed || !isAllowedRollback}
|
||||
isLoading={isRollingBack}
|
||||
leftIcon={<FontAwesomeIcon icon={faUndo} />}
|
||||
className="h-10"
|
||||
|
||||
+13
-5
@@ -1,14 +1,22 @@
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TGcpSync } from "@app/hooks/api/secretSyncs/types/gcp-sync";
|
||||
import { GcpSyncScope, TGcpSync } from "@app/hooks/api/secretSyncs/types/gcp-sync";
|
||||
|
||||
type Props = {
|
||||
secretSync: TGcpSync;
|
||||
};
|
||||
|
||||
export const GcpSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
const {
|
||||
destinationConfig: { projectId }
|
||||
} = secretSync;
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
return <GenericFieldLabel label="Project ID">{projectId}</GenericFieldLabel>;
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Project ID">{destinationConfig.projectId}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Scope" className="capitalize">
|
||||
{destinationConfig.scope}
|
||||
</GenericFieldLabel>
|
||||
{destinationConfig.scope === GcpSyncScope.Region && (
|
||||
<GenericFieldLabel label="Region">{destinationConfig.locationId}</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ import { EnvironmentSection } from "../EnvironmentSection";
|
||||
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
||||
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
|
||||
import { SecretSharingSection } from "../SecretSharingSection";
|
||||
import { SecretSnapshotsLegacySection } from "../SecretSnapshotsLegacySection";
|
||||
import { SecretTagsSection } from "../SecretTagsSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
@@ -24,6 +25,7 @@ export const ProjectGeneralTab = () => {
|
||||
{isSecretManager && <SecretTagsSection />}
|
||||
{isSecretManager && <AutoCapitalizationSection />}
|
||||
{isSecretManager && <SecretSharingSection />}
|
||||
{isSecretManager && <SecretSnapshotsLegacySection />}
|
||||
{isSecretManager && <PointInTimeVersionLimitSection />}
|
||||
<AuditLogsRetentionSection />
|
||||
{isSecretManager && <BackfillSecretReferenceSecretion />}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
export const SecretSnapshotsLegacySection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync: updateProject } = useUpdateProject();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleToggle = async (state: boolean) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!currentWorkspace?.id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateProject({
|
||||
projectID: currentWorkspace.id,
|
||||
showSnapshotsLegacy: state
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${state ? "enabled" : "disabled"} secret snapshots legacy for this project`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update secret snapshots legacy for this project",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-3 text-xl font-semibold">Show Secret Snapshots ( legacy )</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
|
||||
{(isAllowed) => (
|
||||
<div className="w-max">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary"
|
||||
id="showSnapshotsLegacy"
|
||||
isDisabled={!isAllowed || isLoading}
|
||||
isChecked={currentWorkspace?.showSnapshotsLegacy ?? false}
|
||||
onCheckedChange={(state) => handleToggle(state as boolean)}
|
||||
>
|
||||
This feature enables your project members to view secret snapshots in the legacy
|
||||
format.
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretSnapshotsLegacySection } from "./SecretSnapshotsLegacySection";
|
||||
@@ -194,6 +194,7 @@ export const WebhooksTab = withProjectPermission(
|
||||
<Tr key={id}>
|
||||
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
|
||||
{url}
|
||||
<p className="text-xs text-mineshaft-400">{id}</p>
|
||||
</Td>
|
||||
<Td>{environment.slug}</Td>
|
||||
<Td>{secretPath}</Td>
|
||||
|
||||
Reference in New Issue
Block a user