mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-12 06:29:28 +00:00
Merge remote-tracking branch 'origin/main' into feat/ENG-3942
This commit is contained in:
@@ -24,7 +24,7 @@ const SCOPE_NAME: Record<NonNullable<Props["scope"]>, { label: string; icon: Ico
|
||||
[ProjectType.KMS]: { label: "Project", icon: faCube },
|
||||
[ProjectType.PAM]: { label: "Project", icon: faCube },
|
||||
[ProjectType.SecretScanning]: { label: "Project", icon: faCube },
|
||||
namespace: { label: "Namespace", icon: faCubes },
|
||||
namespace: { label: "Sub-Organization", icon: faCubes },
|
||||
instance: { label: "Server", icon: faServer }
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ apiRequest.interceptors.request.use((config) => {
|
||||
const token = getAuthToken();
|
||||
const providerAuthToken = SecurityClient.getProviderAuthToken();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (config.headers) {
|
||||
if (signupTempToken) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
@@ -38,6 +40,17 @@ apiRequest.interceptors.request.use((config) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config.headers.Authorization = `Bearer ${providerAuthToken}`;
|
||||
}
|
||||
|
||||
const rootOrgHeader = config.headers.get("x-root-org");
|
||||
|
||||
if (rootOrgHeader) {
|
||||
config.headers.delete("x-root-org");
|
||||
} else {
|
||||
const subOrganization = params.get("subOrganization");
|
||||
if (subOrganization) {
|
||||
config.headers.set("x-infisical-org", subOrganization);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
@@ -33,10 +33,6 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
"/organization/secret-sharing",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/"
|
||||
),
|
||||
SecretSharingSettings: setRoute(
|
||||
"/organization/secret-sharing/settings",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings"
|
||||
),
|
||||
SettingsPage: setRoute(
|
||||
"/organization/settings",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/settings/"
|
||||
|
||||
@@ -62,7 +62,8 @@ export enum OrgPermissionSubjects {
|
||||
SecretShare = "secret-share",
|
||||
GithubOrgSync = "github-org-sync",
|
||||
GithubOrgSyncManual = "github-org-sync-manual",
|
||||
MachineIdentityAuthTemplate = "machine-identity-auth-template"
|
||||
MachineIdentityAuthTemplate = "machine-identity-auth-template",
|
||||
SubOrganization = "sub-organization"
|
||||
}
|
||||
|
||||
export enum OrgPermissionAdminConsoleAction {
|
||||
@@ -112,6 +113,11 @@ export enum OrgPermissionGroupActions {
|
||||
RemoveMembers = "remove-members"
|
||||
}
|
||||
|
||||
export enum OrgPermissionSubOrgActions {
|
||||
Create = "create",
|
||||
DirectAccess = "direct-access"
|
||||
}
|
||||
|
||||
export type AppConnectionSubjectFields = {
|
||||
connectionId: string;
|
||||
};
|
||||
@@ -151,6 +157,7 @@ export type OrgPermissionSet =
|
||||
| OrgPermissionSubjects.AppConnections
|
||||
| (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields)
|
||||
)
|
||||
];
|
||||
]
|
||||
| [OrgPermissionSubOrgActions, OrgPermissionSubjects.SubOrganization];
|
||||
|
||||
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries";
|
||||
|
||||
@@ -9,11 +10,29 @@ export const useOrganization = () => {
|
||||
select: (el) => el.organizationId
|
||||
});
|
||||
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
const { data: currentOrg } = useSuspenseQuery({
|
||||
queryKey: organizationKeys.getOrgById(organizationId),
|
||||
queryKey: organizationKeys.getOrgById(organizationId, subOrganization || "root"),
|
||||
queryFn: () => fetchOrganizationById(organizationId),
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
return { currentOrg };
|
||||
const org = useMemo(
|
||||
() => ({
|
||||
currentOrg: {
|
||||
...currentOrg,
|
||||
id: currentOrg?.subOrganization?.id || currentOrg?.id,
|
||||
parentOrgId: currentOrg.id
|
||||
},
|
||||
isSubOrganization: Boolean(currentOrg.subOrganization),
|
||||
isRootOrganization: !currentOrg.subOrganization
|
||||
}),
|
||||
[currentOrg, subOrganization]
|
||||
);
|
||||
|
||||
return org;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const withPermission = <T extends object, J extends TOrgPermission>(
|
||||
|
||||
// akhilmhdh: Set as any due to casl/react ts type bug
|
||||
// REASON: casl due to its type checking can't seem to union even if union intersection is applied
|
||||
if (permission.cannot(action as any, subject)) {
|
||||
if (permission.cannot(action as any, subject as any)) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
|
||||
@@ -20,6 +20,7 @@ export type Identity = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isInstanceAdmin?: boolean;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type IdentityAccessToken = {
|
||||
|
||||
@@ -48,6 +48,7 @@ export * from "./sshCertificateTemplates";
|
||||
export * from "./sshHost";
|
||||
export * from "./sshHostGroup";
|
||||
export * from "./ssoConfig";
|
||||
export * from "./subOrganizations";
|
||||
export * from "./subscriptions";
|
||||
export * from "./tags";
|
||||
export * from "./trustedIps";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation";
|
||||
export type {
|
||||
TCreateOrgIdentityMembershipDTO,
|
||||
TDeleteOrgIdentityMembershipDTO,
|
||||
TOrgIdentityMembership
|
||||
} from "./types";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { identitiesKeys } from "../identities";
|
||||
import {
|
||||
TCreateOrgIdentityMembershipDTO,
|
||||
TDeleteOrgIdentityMembershipDTO,
|
||||
TOrgIdentityMembership
|
||||
} from "./types";
|
||||
|
||||
export const useCreateOrgIdentityMembership = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ identityId, roles }: TCreateOrgIdentityMembershipDTO) => {
|
||||
const { data } = await apiRequest.post<{ identityMembership: TOrgIdentityMembership }>(
|
||||
`/api/v1/organization/identity-memberships/${identityId}`,
|
||||
{ roles }
|
||||
);
|
||||
return data.identityMembership;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteOrgIdentityMembership = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ identityId }: TDeleteOrgIdentityMembershipDTO) => {
|
||||
const { data } = await apiRequest.delete<{ identityMembership: TOrgIdentityMembership }>(
|
||||
`/api/v1/organization/identity-memberships/${identityId}`
|
||||
);
|
||||
return data.identityMembership;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentities({ search: {} }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export enum TemporaryPermissionMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type TOrgIdentityMembership = {
|
||||
id: string;
|
||||
orgId: string;
|
||||
identityId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TCreateOrgIdentityMembershipDTO = {
|
||||
identityId: string;
|
||||
roles: Array<
|
||||
| {
|
||||
role: string;
|
||||
isTemporary?: false;
|
||||
}
|
||||
| {
|
||||
role: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: TemporaryPermissionMode;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type TDeleteOrgIdentityMembershipDTO = {
|
||||
identityId: string;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
useDeleteOrgById,
|
||||
useDeleteOrgPmtMethod,
|
||||
useDeleteOrgTaxId,
|
||||
useGetAvailableOrgIdentities,
|
||||
useGetIdentityMembershipOrgs,
|
||||
useGetOrganizationGroups,
|
||||
useGetOrganizations,
|
||||
|
||||
@@ -42,7 +42,9 @@ export const organizationKeys = {
|
||||
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const,
|
||||
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const,
|
||||
getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const,
|
||||
getOrgById: (orgId: string) => ["organization", { orgId }]
|
||||
getOrgById: (orgId: string, subOrg?: string) => ["organization", { orgId, subOrg }],
|
||||
getAvailableIdentities: () => ["available-identities"],
|
||||
getAvailableUsers: () => ["available-users"]
|
||||
};
|
||||
|
||||
export const fetchOrganizations = async () => {
|
||||
@@ -64,7 +66,9 @@ export const useGetOrganizations = () => {
|
||||
export const fetchOrganizationById = async (id: string) => {
|
||||
const {
|
||||
data: { organization }
|
||||
} = await apiRequest.get<{ organization: Organization }>(`/api/v1/organization/${id}`);
|
||||
} = await apiRequest.get<{
|
||||
organization: Organization & { subOrganization?: { id: string; name: string } };
|
||||
}>(`/api/v1/organization/${id}`);
|
||||
return organization;
|
||||
};
|
||||
|
||||
@@ -572,3 +576,29 @@ export const useGetOrgIntegrationAuths = <TData = IntegrationAuth[],>(
|
||||
select
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAvailableOrgIdentities = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: organizationKeys.getAvailableIdentities(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>(
|
||||
"/api/v1/organization/identities/available"
|
||||
);
|
||||
|
||||
return data.identities;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
|
||||
export const useGetAvailableOrgUsers = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: organizationKeys.getAvailableUsers(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
users: { username: string; id: string; firstName: string; lastName: string }[];
|
||||
}>("/api/v1/organization/users/available");
|
||||
|
||||
return data.users;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";
|
||||
@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TPamResourceOption } from "./types/resource-options";
|
||||
import { PamResourceType } from "./enums";
|
||||
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
|
||||
|
||||
export const pamKeys = {
|
||||
@@ -12,6 +13,12 @@ export const pamKeys = {
|
||||
session: () => [...pamKeys.all, "session"] as const,
|
||||
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
|
||||
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
|
||||
getResource: (resourceType: string, resourceId: string) => [
|
||||
...pamKeys.resource(),
|
||||
"get",
|
||||
resourceType,
|
||||
resourceId
|
||||
],
|
||||
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
|
||||
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
||||
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
||||
@@ -68,6 +75,28 @@ export const useListPamResources = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPamResourceById = (
|
||||
resourceType?: PamResourceType,
|
||||
resourceId?: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<TPamResource, unknown, TPamResource, ReturnType<typeof pamKeys.getResource>>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.getResource(resourceType || "", resourceId || ""),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ resource: TPamResource }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}`
|
||||
);
|
||||
|
||||
return data.resource;
|
||||
},
|
||||
enabled: !!resourceId && !!resourceType && (options?.enabled ?? true),
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export const useListPamAccounts = (
|
||||
projectId: string,
|
||||
|
||||
@@ -9,9 +9,13 @@ export interface TBasePamAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
rotationCredentialsConfigured: boolean;
|
||||
};
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
lastRotatedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
|
||||
// Resources
|
||||
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
|
||||
connectionDetails: TBaseSqlConnectionDetails;
|
||||
rotationAccountCredentials?: TBaseSqlCredentials | null;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useCreateSubOrganization, useUpdateSubOrganization } from "./mutations";
|
||||
export { subOrganizationsQuery } from "./queries";
|
||||
export type {
|
||||
TCreateSubOrganizationDTO,
|
||||
TListSubOrganizationsDTO,
|
||||
TSubOrganization,
|
||||
TUpdateSubOrganizationDTO
|
||||
} from "./types";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { subOrganizationsQuery } from "./queries";
|
||||
import { TCreateSubOrganizationDTO, TSubOrganization, TUpdateSubOrganizationDTO } from "./types";
|
||||
|
||||
export const useCreateSubOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (dto: TCreateSubOrganizationDTO) => {
|
||||
const { data } = await apiRequest.post<{ organization: TSubOrganization }>(
|
||||
"/api/v1/sub-organizations",
|
||||
dto,
|
||||
{
|
||||
headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSubOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ subOrgId, name }: TUpdateSubOrganizationDTO) => {
|
||||
const { data } = await apiRequest.patch<{ organization: TSubOrganization }>(
|
||||
`/api/v1/sub-organizations/${subOrgId}`,
|
||||
{ name }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TListSubOrganizationsDTO, TSubOrganization } from "./types";
|
||||
|
||||
export const subOrganizationsQuery = {
|
||||
allKey: () => ["sub-organizations"] as const,
|
||||
listKey: (params?: TListSubOrganizationsDTO) =>
|
||||
[...subOrganizationsQuery.allKey(), "list", params] as const,
|
||||
list: (params: TListSubOrganizationsDTO) =>
|
||||
queryOptions({
|
||||
queryKey: subOrganizationsQuery.listKey(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ organizations: TSubOrganization[] }>(
|
||||
"/api/v1/sub-organizations",
|
||||
{
|
||||
params: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
isAccessible: params.isAccessible
|
||||
}
|
||||
}
|
||||
);
|
||||
return data.organizations;
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export type TSubOrganization = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
parentOrgId: string;
|
||||
};
|
||||
|
||||
export type TCreateSubOrganizationDTO = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TListSubOrganizationsDTO = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
isAccessible?: boolean;
|
||||
};
|
||||
|
||||
export type TUpdateSubOrganizationDTO = {
|
||||
subOrgId: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export type SubscriptionPlan = {
|
||||
customRateLimits: boolean;
|
||||
pitRecovery: boolean;
|
||||
githubOrgSync: boolean;
|
||||
subOrganization?: boolean;
|
||||
ipAllowlisting: boolean;
|
||||
rbac: boolean;
|
||||
secretVersioning: boolean;
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
faBook,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faChevronRight,
|
||||
faCubes,
|
||||
faEnvelope,
|
||||
faExclamationTriangle,
|
||||
faGlobe,
|
||||
faInfinity,
|
||||
faInfo,
|
||||
faInfoCircle,
|
||||
faPlus,
|
||||
faServer,
|
||||
faSignOut,
|
||||
faToolbox,
|
||||
@@ -19,7 +22,7 @@ import {
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -34,6 +37,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -44,15 +50,22 @@ import { envConfig } from "@app/config/env";
|
||||
import { useOrganization, useSubscription, useUser } from "@app/context";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { projectKeys, useGetOrganizations, useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api";
|
||||
import {
|
||||
projectKeys,
|
||||
subOrganizationsQuery,
|
||||
useGetOrganizations,
|
||||
useGetOrgTrialUrl,
|
||||
useLogoutUser
|
||||
} from "@app/hooks/api";
|
||||
import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { getAuthToken } from "@app/hooks/api/reactQuery";
|
||||
import { SubscriptionPlan } from "@app/hooks/api/types";
|
||||
import { Organization, SubscriptionPlan } from "@app/hooks/api/types";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
|
||||
|
||||
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
|
||||
import { NewSubOrganizationForm } from "./NewSubOrganizationForm";
|
||||
import { NotificationDropdown } from "./NotificationDropdown";
|
||||
|
||||
const getPlan = (subscription: SubscriptionPlan) => {
|
||||
@@ -119,10 +132,18 @@ export const INFISICAL_SUPPORT_OPTIONS = [
|
||||
export const Navbar = () => {
|
||||
const { user } = useUser();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
const [showAdminsModal, setShowAdminsModal] = useState(false);
|
||||
const [showSubOrgForm, setShowSubOrgForm] = useState(false);
|
||||
const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false);
|
||||
|
||||
const subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true });
|
||||
const { data: subOrganizations = [] } = useQuery({
|
||||
...subOrgQuery,
|
||||
enabled: Boolean(subscription.subOrganization)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) {
|
||||
sessionStorage.setItem("paymentFailed", "true");
|
||||
@@ -137,6 +158,7 @@ export const Navbar = () => {
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false);
|
||||
|
||||
const location = useLocation();
|
||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||
@@ -161,6 +183,7 @@ export const Navbar = () => {
|
||||
}
|
||||
await router.invalidate();
|
||||
await navigateUserToOrg(navigate, orgId);
|
||||
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
|
||||
};
|
||||
|
||||
const { mutateAsync } = useGetOrgTrialUrl();
|
||||
@@ -206,6 +229,33 @@ export const Navbar = () => {
|
||||
|
||||
const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate?
|
||||
|
||||
const handleOrgNav = async (org: Organization) => {
|
||||
if (currentOrg?.id === org.id) return;
|
||||
|
||||
if (org.authEnforced) {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
|
||||
await logout.mutateAsync();
|
||||
if (org.orgAuthMethod === AuthMethod.OIDC) {
|
||||
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
|
||||
} else {
|
||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
|
||||
}
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.googleSsoAuthEnforced) {
|
||||
await logout.mutateAsync();
|
||||
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
handleOrgChange(org?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 flex min-h-12 items-center bg-mineshaft-900 px-4 pt-1">
|
||||
<div className="mr-auto flex items-center overflow-hidden">
|
||||
@@ -235,38 +285,45 @@ export const Navbar = () => {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<DropdownMenu modal={false}>
|
||||
<Link className="overflow-hidden" to="/organization/projects">
|
||||
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
|
||||
<Badge
|
||||
variant="org"
|
||||
className={twMerge(
|
||||
"max-w-full min-w-0 cursor-pointer text-sm",
|
||||
!isOrgScope &&
|
||||
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
<p className="truncate">{currentOrg?.name}</p>
|
||||
</Badge>
|
||||
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
|
||||
{getPlan(subscription)}
|
||||
</div>
|
||||
{subscription.cardDeclined && (
|
||||
<Tooltip
|
||||
content={`Your payment could not be processed${subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. Please update your payment method to continue enjoying premium features.`}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationTriangle}
|
||||
className="animate-pulse cursor-help text-xs text-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<DropdownMenu modal={false} open={isOrgSelectOpen} onOpenChange={setIsOrgSelectOpen}>
|
||||
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
|
||||
<Badge
|
||||
onClick={async () => {
|
||||
navigate({
|
||||
to: "/organization/projects",
|
||||
search: (search) => ({ ...search, subOrganization: undefined })
|
||||
});
|
||||
if (isSubOrganization) {
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
}
|
||||
}}
|
||||
variant="org"
|
||||
className={twMerge(
|
||||
"max-w-full min-w-0 cursor-pointer text-sm",
|
||||
(!isOrgScope || isSubOrganization) &&
|
||||
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
<p className="truncate">{currentOrg?.name}</p>
|
||||
</Badge>
|
||||
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
|
||||
{getPlan(subscription)}
|
||||
</div>
|
||||
</Link>
|
||||
{subscription.cardDeclined && (
|
||||
<Tooltip
|
||||
content={`Your payment could not be processed${subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. Please update your payment method to continue enjoying premium features.`}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationTriangle}
|
||||
className="animate-pulse cursor-help text-xs text-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
<IconButton
|
||||
@@ -280,58 +337,95 @@ export const Navbar = () => {
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align="center"
|
||||
side="bottom"
|
||||
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
||||
style={{ minWidth: "220px" }}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
|
||||
organizations
|
||||
Organizations
|
||||
</div>
|
||||
{orgs?.map((org) => {
|
||||
if (
|
||||
subscription.subOrganization &&
|
||||
(org.id === currentOrg?.id || org.id === currentOrg?.parentOrgId)
|
||||
) {
|
||||
return (
|
||||
<DropdownSubMenu key={`${org.id}-sub-orgs`}>
|
||||
<DropdownSubMenuTrigger
|
||||
onClick={() => {
|
||||
setIsOrgSelectOpen(false);
|
||||
handleOrgNav(org);
|
||||
}}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
|
||||
{currentOrg?.id === org.id && (
|
||||
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<p className="truncate">{org.name}</p>
|
||||
<FontAwesomeIcon className="ml-auto shrink-0" icon={faChevronRight} />
|
||||
</div>
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent
|
||||
sideOffset={8}
|
||||
alignOffset={-24}
|
||||
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
||||
style={{ minWidth: "220px" }}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
|
||||
Sub-Organizations
|
||||
</div>
|
||||
{subOrganizations.map((subOrg) => (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
navigate({
|
||||
to: "/organization/projects",
|
||||
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
}}
|
||||
className="cursor-pointer font-normal"
|
||||
key={subOrg.id}
|
||||
>
|
||||
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
|
||||
{currentOrg?.id === subOrg.id && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="shrink-0 text-primary"
|
||||
/>
|
||||
)}
|
||||
<p className="truncate">{subOrg.name}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{Boolean(subOrganizations.length) && (
|
||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
icon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => setShowSubOrgForm(true)}
|
||||
>
|
||||
New Sub-Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={org.id}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (currentOrg?.id === org.id) return;
|
||||
|
||||
if (org.authEnforced) {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
|
||||
await logout.mutateAsync();
|
||||
if (org.orgAuthMethod === AuthMethod.OIDC) {
|
||||
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
|
||||
} else {
|
||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
|
||||
}
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.googleSsoAuthEnforced) {
|
||||
await logout.mutateAsync();
|
||||
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
handleOrgChange(org?.id);
|
||||
}}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
size="xs"
|
||||
className="flex w-full items-center justify-start p-0 font-normal"
|
||||
leftIcon={
|
||||
currentOrg?.id === org.id && (
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
||||
{org.name}
|
||||
</div>
|
||||
</Button>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOrgNav(org)}
|
||||
className="cursor-pointer font-normal"
|
||||
key={org.id}
|
||||
>
|
||||
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
|
||||
{currentOrg?.id === org.id && (
|
||||
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<p className="truncate">{org.name}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -345,6 +439,78 @@ export const Navbar = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{currentOrg.subOrganization && (
|
||||
<>
|
||||
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
|
||||
<DropdownMenu modal={false}>
|
||||
<Link className="overflow-hidden" to="/organization/projects">
|
||||
<Badge
|
||||
variant="namespace"
|
||||
className={twMerge(
|
||||
"flex max-w-full min-w-0 cursor-pointer text-sm",
|
||||
!isOrgScope &&
|
||||
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCubes} />
|
||||
<p className="truncate">{currentOrg.subOrganization.name}</p>
|
||||
</Badge>
|
||||
</Link>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="switch-org"
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
side="bottom"
|
||||
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
||||
style={{ minWidth: "220px" }}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
|
||||
Sub-Organizations
|
||||
</div>
|
||||
{subOrganizations.map((subOrg) => (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
navigate({
|
||||
to: "/organization/projects",
|
||||
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
}}
|
||||
className="cursor-pointer font-normal"
|
||||
key={subOrg.id}
|
||||
>
|
||||
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
|
||||
{currentOrg?.id === subOrg.id && (
|
||||
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<p className="truncate">{subOrg.name}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{Boolean(subOrganizations.length) && (
|
||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
icon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => setShowSubOrgForm(true)}
|
||||
>
|
||||
New Sub-Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
{!isOrgScope && (
|
||||
<>
|
||||
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
|
||||
@@ -517,6 +683,7 @@ export const Navbar = () => {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Modal isOpen={showCardDeclinedModal} onOpenChange={setShowCardDeclinedModal}>
|
||||
<ModalContent
|
||||
title={
|
||||
@@ -560,6 +727,20 @@ export const Navbar = () => {
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal isOpen={showSubOrgForm} onOpenChange={setShowSubOrgForm}>
|
||||
<ModalContent
|
||||
title="Create Sub-Organizations"
|
||||
subTitle="Define a new sub-organization under your current organization."
|
||||
>
|
||||
<div className="mb-2">
|
||||
<NewSubOrganizationForm
|
||||
onClose={() => {
|
||||
setShowSubOrgForm(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
|
||||
<ModalContent title="Server Administrators" subTitle="View all server administrators">
|
||||
<div className="mb-2">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useCreateSubOrganization } from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const AddOrgSchema = z.object({
|
||||
name: slugSchema()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddOrgSchema>;
|
||||
|
||||
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
|
||||
const createSubOrg = useCreateSubOrganization();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: ""
|
||||
},
|
||||
resolver: zodResolver(AddOrgSchema)
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async ({ name }: FormData) => {
|
||||
try {
|
||||
const { organization } = await createSubOrg.mutateAsync({
|
||||
name
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created sub organization"
|
||||
});
|
||||
onClose();
|
||||
|
||||
navigate({
|
||||
to: "/organization/projects",
|
||||
search: (prev) => ({ ...prev, subOrganization: organization.name })
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to create sub organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
helperText="Must be slug-friendly"
|
||||
errorText={error?.message}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus value={value} onChange={onChange} placeholder="example-team" />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Add Sub-Organization
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { motion } from "framer-motion";
|
||||
|
||||
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
|
||||
import { Tab, TabList, Tabs } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
@@ -10,10 +11,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export const OrgNavBar = ({ isHidden }: Props) => {
|
||||
const { isRootOrganization } = useOrganization();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const variant = isRootOrganization ? "org" : "namespace";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isHidden && (
|
||||
@@ -30,28 +34,28 @@ export const OrgNavBar = ({ isHidden }: Props) => {
|
||||
<TabList className="border-b-0">
|
||||
<Link to="/organization/projects">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
Overview
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/organization/app-connections">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
App Connections
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/organization/networking">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
Networking
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/organization/secret-sharing">
|
||||
{({ isActive }) => (
|
||||
<Tab value={isActive ? "selected" : ""} variant="org">
|
||||
<Tab value={isActive ? "selected" : ""} variant={variant}>
|
||||
Secret Sharing
|
||||
</Tab>
|
||||
)}
|
||||
@@ -59,7 +63,7 @@ export const OrgNavBar = ({ isHidden }: Props) => {
|
||||
<Link to="/organization/access-management">
|
||||
{({ isActive }) => (
|
||||
<Tab
|
||||
variant="org"
|
||||
variant={variant}
|
||||
value={
|
||||
isActive ||
|
||||
pathname.match(
|
||||
@@ -75,21 +79,23 @@ export const OrgNavBar = ({ isHidden }: Props) => {
|
||||
</Link>
|
||||
<Link to="/organization/audit-logs">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
Audit Logs
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/organization/billing">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
Usage & Billing
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
{isRootOrganization && (
|
||||
<Link to="/organization/billing">
|
||||
{({ isActive }) => (
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
Usage & Billing
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/organization/settings">
|
||||
{({ isActive }) => (
|
||||
<Tab variant="org" value={isActive ? "selected" : ""}>
|
||||
<Tab variant={variant} value={isActive ? "selected" : ""}>
|
||||
Settings
|
||||
</Tab>
|
||||
)}
|
||||
|
||||
@@ -171,9 +171,21 @@ export const ProjectSelect = () => {
|
||||
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||
params: {
|
||||
projectId: workspace.id
|
||||
},
|
||||
search: {
|
||||
subOrganization: currentOrg?.subOrganization?.name
|
||||
}
|
||||
});
|
||||
window.location.assign(url.to.replaceAll("$projectId", workspace.id));
|
||||
const urlInstance = new URL(
|
||||
`${window.location.origin}/${url.to.replaceAll("$projectId", workspace.id)}`
|
||||
);
|
||||
if (currentOrg?.subOrganization) {
|
||||
urlInstance.searchParams.set(
|
||||
"subOrganization",
|
||||
currentOrg.subOrganization.name
|
||||
);
|
||||
}
|
||||
window.location.assign(urlInstance);
|
||||
}}
|
||||
icon={
|
||||
currentWorkspace?.id === workspace.id && (
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SignupInvitePage = () => {
|
||||
|
||||
// Step 4 of the sign up process (download the emergency kit pdf)
|
||||
const stepConfirmEmail = (
|
||||
<div className="mx-1 mb-36 flex h-7/12 w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<div className="mx-1 mt-14 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<p className="mb-2 flex justify-center text-center text-4xl font-medium text-primary-100">
|
||||
Confirm your email
|
||||
</p>
|
||||
@@ -179,7 +179,7 @@ export const SignupInvitePage = () => {
|
||||
|
||||
// Because this is the invite signup - we directly go to the last step of signup (email is already verified)
|
||||
const main = (
|
||||
<div className="mx-auto mb-32 h-7/12 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
|
||||
<div className="mx-auto mt-14 mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
|
||||
<p className="mx-8 mb-6 flex justify-center bg-linear-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
|
||||
Almost there!
|
||||
</p>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "
|
||||
export const AccessManagementPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { permission } = useOrgPermission();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
const navigate = useNavigate({
|
||||
from: ROUTE_PATHS.Organization.AccessControlPage.path
|
||||
@@ -82,7 +82,7 @@ export const AccessManagementPage = () => {
|
||||
</Helmet>
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title="Access Control"
|
||||
description="Manage fine-grained access for users, groups, roles, and identities within your organization resources."
|
||||
/>
|
||||
@@ -116,7 +116,11 @@ export const AccessManagementPage = () => {
|
||||
{tabSections
|
||||
.filter((el) => !el.isHidden)
|
||||
.map((el) => (
|
||||
<Tab variant="org" value={el.key} key={`org-access-tab-${el.key}`}>
|
||||
<Tab
|
||||
variant={isSubOrganization ? "namespace" : "org"}
|
||||
value={el.key}
|
||||
key={`org-access-tab-${el.key}`}
|
||||
>
|
||||
{el.label}
|
||||
</Tab>
|
||||
))}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useGetAvailableOrgIdentities, useGetOrgRoles } from "@app/hooks/api";
|
||||
import { useCreateOrgIdentityMembership } from "@app/hooks/api/orgIdentityMembership";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
identity: z.object({ name: z.string(), id: z.string() }),
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const IdentityLinkForm = ({ onClose }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
|
||||
const { mutateAsync: createMutateAsync } = useCreateOrgIdentityMembership();
|
||||
const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useGetAvailableOrgIdentities();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ identity, role }: FormData) => {
|
||||
try {
|
||||
await createMutateAsync({
|
||||
identityId: identity.id,
|
||||
roles: [{ role: role.slug, isTemporary: false }]
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully linked identity",
|
||||
type: "success"
|
||||
});
|
||||
navigate({
|
||||
to: "/organization/identities/$identityId",
|
||||
params: {
|
||||
identityId: identity.id
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to link identity";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="identity"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select identity..."
|
||||
options={rootOrgIdentities}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isLoading={isRootOrgLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
// menuPortalTarget={document.body}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain" onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+39
-29
@@ -53,6 +53,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const isOrgIdentity = popUp?.identity?.data ? orgId === popUp?.identity?.data?.orgId : true;
|
||||
|
||||
const { mutateAsync: createMutateAsync } = useCreateIdentity();
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
|
||||
@@ -113,6 +114,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
name: string;
|
||||
role: string;
|
||||
hasDeleteProtection: boolean;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
if (identity) {
|
||||
@@ -196,16 +198,23 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="Machine 1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{isOrgIdentity && (
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-4"
|
||||
label="Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Machine 1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
@@ -214,7 +223,6 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
@@ -227,24 +235,26 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="hasDeleteProtection"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl errorText={error?.message} isError={Boolean(error)}>
|
||||
<Switch
|
||||
className="mr-2 ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
|
||||
containerClassName="flex-row-reverse w-fit"
|
||||
id="delete-protection-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
>
|
||||
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{isOrgIdentity && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="hasDeleteProtection"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl errorText={error?.message} isError={Boolean(error)}>
|
||||
<Switch
|
||||
className="mr-2 ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
|
||||
containerClassName="flex-row-reverse w-fit"
|
||||
id="delete-protection-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
>
|
||||
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<FormLabel label="Metadata" />
|
||||
</div>
|
||||
|
||||
+45
-16
@@ -1,10 +1,15 @@
|
||||
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faLink,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects,
|
||||
@@ -19,6 +24,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityAuthTemplateModal } from "./IdentityAuthTemplateModal";
|
||||
import { IdentityAuthTemplatesTable } from "./IdentityAuthTemplatesTable";
|
||||
import { IdentityLinkForm } from "./IdentityLinkForm";
|
||||
import { IdentityModal } from "./IdentityModal";
|
||||
import { IdentityTable } from "./IdentityTable";
|
||||
import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal";
|
||||
@@ -27,7 +33,7 @@ import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal
|
||||
export const IdentitySection = withPermission(
|
||||
() => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentity();
|
||||
@@ -43,7 +49,8 @@ export const IdentitySection = withPermission(
|
||||
"createTemplate",
|
||||
"editTemplate",
|
||||
"deleteTemplate",
|
||||
"viewUsages"
|
||||
"viewUsages",
|
||||
"linkIdentity"
|
||||
] as const);
|
||||
|
||||
const isMoreIdentitiesAllowed = subscription?.identityLimit
|
||||
@@ -105,8 +112,8 @@ export const IdentitySection = withPermission(
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="mb-4 flex w-full items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/identities/overview"
|
||||
@@ -123,6 +130,26 @@ export const IdentitySection = withPermission(
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{isSubOrganization && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Create}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faLink} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("linkIdentity");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Link Identity
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Create}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
@@ -210,16 +237,18 @@ export const IdentitySection = withPermission(
|
||||
?.name || ""
|
||||
}
|
||||
/>
|
||||
{/* <IdentityAuthMethodModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/> */}
|
||||
{/* <IdentityUniversalAuthClientSecretModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/> */}
|
||||
<Modal
|
||||
isOpen={popUp.linkIdentity.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("linkIdentity", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Assign Existing Identity"
|
||||
subTitle="Assign an existing identity from your root organization to the sub organization. The identity will continue to be managed at its original scope."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<IdentityLinkForm onClose={() => handlePopUpClose("linkIdentity")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<IdentityTokenAuthTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
|
||||
+15
-3
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faBuilding,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEdit,
|
||||
@@ -78,7 +79,7 @@ type Filter = {
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
const {
|
||||
offset,
|
||||
@@ -286,15 +287,18 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
{isSubOrganization && <Th>Managed By</Th>}
|
||||
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={3} innerKey="org-identities" />}
|
||||
{isPending && (
|
||||
<TableSkeleton columns={isSubOrganization ? 4 : 3} innerKey="org-identities" />
|
||||
)}
|
||||
{!isPending &&
|
||||
data?.identities?.map(
|
||||
({
|
||||
identity: { id, name },
|
||||
identity: { id, name, orgId },
|
||||
role,
|
||||
customRole,
|
||||
lastLoginAuthMethod,
|
||||
@@ -362,6 +366,14 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
{isSubOrganization && (
|
||||
<Td>
|
||||
<p className="truncate">
|
||||
<FontAwesomeIcon size="sm" className="mr-1.5" icon={faBuilding} />
|
||||
{currentOrg.id === orgId ? "Sub Organization" : "Root Organization"}
|
||||
</p>
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { RoleOption } from "@app/components/roles";
|
||||
import { Button, FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useAddUserToWsNonE2EE,
|
||||
useGetOrgRoles,
|
||||
useGetUserProjects
|
||||
} from "@app/hooks/api";
|
||||
import { useGetAvailableOrgUsers } from "@app/hooks/api/organization/queries";
|
||||
import { ProjectType, ProjectVersion } from "@app/hooks/api/projects/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
users: z
|
||||
.array(
|
||||
z.object({
|
||||
username: z.string().trim(),
|
||||
email: z.string().trim()
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
projects: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
version: z.nativeEnum(ProjectVersion)
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||
organizationRole: z.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const AddSubOrgMemberModal = ({ onClose }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
|
||||
const { data: members = [], isPending: isMembersPending } = useGetAvailableOrgUsers();
|
||||
|
||||
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
|
||||
const { mutateAsync: addUserToProject } = useAddUserToWsNonE2EE();
|
||||
|
||||
const { data: projects, isPending: isProjectsLoading } = useGetUserProjects({
|
||||
includeRoles: true
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddMemberForm>({
|
||||
resolver: zodResolver(addMemberFormSchema)
|
||||
});
|
||||
|
||||
// set initial form role based off org default role
|
||||
useEffect(() => {
|
||||
if (organizationRoles) {
|
||||
reset({
|
||||
organizationRole: findOrgMembershipRole(organizationRoles, currentOrg.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [organizationRoles]);
|
||||
|
||||
const onAddMembers = async ({
|
||||
users,
|
||||
organizationRole,
|
||||
projects: selectedProjects,
|
||||
projectRoleSlug
|
||||
}: TAddMemberForm) => {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
if (selectedProjects?.length) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const project of selectedProjects) {
|
||||
if (project.version !== ProjectVersion.V3) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const usernames = users.map((el) => el.username);
|
||||
await addUsersMutateAsync({
|
||||
organizationId: currentOrg?.id,
|
||||
inviteeEmails: usernames,
|
||||
organizationRoleSlug: organizationRole.slug
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
selectedProjects.map((el) =>
|
||||
addUserToProject({
|
||||
orgId: currentOrg.id,
|
||||
projectId: el.id,
|
||||
roleSlugs: [projectRoleSlug],
|
||||
usernames
|
||||
})
|
||||
)
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to add user to suborganization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getGroupHeaderLabel = (type: ProjectType) => {
|
||||
switch (type) {
|
||||
case ProjectType.SecretManager:
|
||||
return "Secrets";
|
||||
case ProjectType.CertificateManager:
|
||||
return "PKI";
|
||||
case ProjectType.KMS:
|
||||
return "KMS";
|
||||
case ProjectType.SSH:
|
||||
return "SSH";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onAddMembers)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Emails" isError={Boolean(error)} errorText={error?.message}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Add one or more users..."
|
||||
isMulti
|
||||
name="members"
|
||||
isLoading={isMembersPending}
|
||||
options={members}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
getOptionValue={(option) => option.username}
|
||||
getOptionLabel={(option) => option.username}
|
||||
/* eslint-disable-next-line react/no-unstable-nested-components */
|
||||
noOptionsMessage={() => (
|
||||
<p>All root organization users are already assigned to this project</p>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="organizationRole"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which organization role you want to assign to the user."
|
||||
label="Assign organization role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={organizationRoles}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
components={{ Option: RoleOption }}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="projects"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assign users to projects"
|
||||
isOptional
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isLoading={isProjectsLoading}
|
||||
getOptionLabel={(project) => project.name}
|
||||
getOptionValue={(project) => project.id}
|
||||
options={projects}
|
||||
groupBy="type"
|
||||
getGroupHeaderLabel={getGroupHeaderLabel}
|
||||
placeholder="Select projects..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-[0.15rem] flex min-w-fit justify-end">
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which role to assign to the users in the selected projects."
|
||||
label="Role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={watch("projects", []).length === 0}
|
||||
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{Object.entries(ProjectMembershipRole).map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
([_, slug]) =>
|
||||
slug !== "custom" && (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
<span className="capitalize">{slug.replace("-", " ")}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
<Button colorSchema="secondary" variant="plain" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+16
-2
@@ -11,6 +11,8 @@ import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmailServiceSetupModal,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
@@ -26,11 +28,12 @@ import { OrgUser } from "@app/hooks/api/users/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddOrgMemberModal } from "./AddOrgMemberModal";
|
||||
import { AddSubOrgMemberModal } from "./AddSubOrgMemberModal";
|
||||
import { OrgMembersTable } from "./OrgMembersTable";
|
||||
|
||||
export const OrgMembersSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id ?? "";
|
||||
const { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
@@ -41,6 +44,7 @@ export const OrgMembersSection = () => {
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"addMember",
|
||||
"addMemberToSubOrg",
|
||||
"removeMember",
|
||||
"deactivateMember",
|
||||
"upgradePlan",
|
||||
@@ -210,7 +214,9 @@ export const OrgMembersSection = () => {
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddMemberModal()}
|
||||
onClick={() =>
|
||||
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
|
||||
}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Member
|
||||
@@ -230,6 +236,14 @@ export const OrgMembersSection = () => {
|
||||
completeInviteLinks={completeInviteLinks}
|
||||
setCompleteInviteLinks={setCompleteInviteLinks}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addMemberToSubOrg.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addMemberToSubOrg", isOpen)}
|
||||
>
|
||||
<ModalContent title="Add member from your organization">
|
||||
<AddSubOrgMemberModal onClose={() => handlePopUpClose("addMemberToSubOrg")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
title={`Are you sure you want to remove member with username ${
|
||||
|
||||
+2
-1
@@ -109,7 +109,7 @@ export const OrgMembersTable = ({
|
||||
}: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
@@ -586,6 +586,7 @@ export const OrgMembersTable = ({
|
||||
{isActive &&
|
||||
(status === "invited" || status === "verified") &&
|
||||
email &&
|
||||
!isSubOrganization &&
|
||||
serverDetails?.emailConfigured && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
|
||||
+4
-2
@@ -68,7 +68,7 @@ enum RolesOrderBy {
|
||||
|
||||
export const OrgRoleTable = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -200,7 +200,9 @@ export const OrgRoleTable = () => {
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Organization Roles</p>
|
||||
<p className="text-xl font-medium text-mineshaft-100">
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Roles
|
||||
</p>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
OrgPermissionAppConnectionActions,
|
||||
OrgPermissionSubjects
|
||||
@@ -12,6 +13,8 @@ import { AppConnectionsTable } from "@app/pages/organization/AppConnections/AppC
|
||||
|
||||
export const AppConnectionsPage = withPermission(
|
||||
() => {
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="bg-bunker-800">
|
||||
<Helmet>
|
||||
@@ -22,7 +25,7 @@ export const AppConnectionsPage = withPermission(
|
||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
className="w-full"
|
||||
title="App Connections"
|
||||
description="Manage organization App Connections"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppConnectionsPage } from "./AppConnectionsPage";
|
||||
|
||||
@@ -6,6 +7,11 @@ export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/"
|
||||
)({
|
||||
component: AppConnectionsPage,
|
||||
validateSearch: z.object({
|
||||
error: z.string().optional(),
|
||||
success: z.string().optional(),
|
||||
connectionId: z.string().optional()
|
||||
}),
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { LogsSection } from "./components";
|
||||
|
||||
export const AuditLogsPage = () => {
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
@@ -15,7 +18,7 @@ export const AuditLogsPage = () => {
|
||||
<div className="flex w-full justify-center bg-bunker-800 pb-6 text-white">
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title="Audit Logs"
|
||||
description="Audit logs for security and compliance teams to monitor information access."
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
import { BillingPage } from "./BillingPage";
|
||||
|
||||
@@ -6,7 +6,14 @@ export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/billing"
|
||||
)({
|
||||
component: BillingPage,
|
||||
beforeLoad: () => {
|
||||
beforeLoad: ({ search }) => {
|
||||
if (search.subOrganization) {
|
||||
throw redirect({
|
||||
to: "/organization/projects",
|
||||
search
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { useGetGroupById } from "@app/hooks/api/groups/queries";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
@@ -45,6 +45,8 @@ const Page = () => {
|
||||
|
||||
const { data, isPending } = useGetGroupById(groupId);
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -95,7 +97,11 @@ const Page = () => {
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Groups
|
||||
</Link>
|
||||
<PageHeader scope="org" description="Organization Group" title={data.group.name}>
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
description={`${isSubOrganization ? "Sub-" : ""}Organization Group`}
|
||||
title={data.group.name}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
|
||||
+15
-4
@@ -29,10 +29,11 @@ const Page = () => {
|
||||
from: ROUTE_PATHS.Organization.IdentityDetailsByIDPage.id
|
||||
});
|
||||
const identityId = params.identityId as string;
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data } = useGetIdentityById(identityId);
|
||||
const { mutateAsync: deleteIdentity } = useDeleteIdentity();
|
||||
const isAuthHidden = orgId !== data?.identity?.orgId;
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
@@ -87,14 +88,24 @@ const Page = () => {
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Identities
|
||||
</Link>
|
||||
<PageHeader scope="org" description="Organization Identity" title={data.identity.name} />
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
description={`${isSubOrganization ? "Sub-" : ""}Organization Identity`}
|
||||
title={data.identity.name}
|
||||
/>
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<IdentityDetailsSection identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
|
||||
<IdentityAuthenticationSection
|
||||
<IdentityDetailsSection
|
||||
isOrgIdentity={data.identity.orgId === currentOrg.id}
|
||||
identityId={identityId}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
{!isAuthHidden && (
|
||||
<IdentityAuthenticationSection
|
||||
identityId={identityId}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IdentityProjectsSection identityId={identityId} />
|
||||
</div>
|
||||
|
||||
+37
-20
@@ -21,23 +21,25 @@ import {
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
isOrgIdentity?: boolean;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) => {
|
||||
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdentity }: Props) => {
|
||||
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
|
||||
initialState: "Copy ID to clipboard"
|
||||
});
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const { data } = useGetIdentityById(identityId);
|
||||
return data ? (
|
||||
@@ -75,6 +77,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
|
||||
handlePopUpOpen("identity", {
|
||||
identityId,
|
||||
name: data.identity.name,
|
||||
orgId: data.identity.orgId,
|
||||
hasDeleteProtection: data.identity.hasDeleteProtection,
|
||||
role: data.role,
|
||||
customRole: data.customRole,
|
||||
@@ -140,24 +143,38 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
|
||||
<p className="text-sm font-medium text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.identity.hasDeleteProtection ? "On" : "Off"}
|
||||
</p>
|
||||
</div>
|
||||
{isSubOrganization && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Managed By</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{isOrgIdentity ? "Organization" : "Root Organization"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.identity.hasDeleteProtection ? "On" : "Off"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.role}</p>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { NetworkingTabGroup } from "./components/NetworkingTabGroup/NetworkingTabGroup";
|
||||
|
||||
export const NetworkingPage = () => {
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -14,7 +17,7 @@ export const NetworkingPage = () => {
|
||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title="Networking"
|
||||
description="Manage gateways and relays to securely access private network resources from Infisical"
|
||||
/>
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { GatewayTab } from "../GatewayTab/GatewayTab";
|
||||
import { RelayTab } from "../RelayTab/RelayTab";
|
||||
@@ -18,11 +19,13 @@ export const NetworkingTabGroup = () => {
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabList>
|
||||
{tabs.map((tab) => (
|
||||
<Tab variant="org" value={tab.key} key={tab.key}>
|
||||
<Tab variant={isSubOrganization ? "namespace" : "org"} value={tab.key} key={tab.key}>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { NewProjectModal } from "@app/components/projects";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AllProjectView } from "./components/AllProjectView";
|
||||
@@ -52,7 +52,7 @@ export const ProjectsPage = () => {
|
||||
] as const);
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||
: true;
|
||||
@@ -64,7 +64,7 @@ export const ProjectsPage = () => {
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title="Overview"
|
||||
description="Your team's complete security toolkit - organized and ready when you need them."
|
||||
/>
|
||||
|
||||
@@ -378,7 +378,7 @@ export const MyProjectView = ({
|
||||
<div className="flex w-full flex-row">
|
||||
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50/60 duration-200 focus:bg-mineshaft-700/80"
|
||||
containerClassName="w-full ml-2"
|
||||
placeholder="Search by project name..."
|
||||
value={searchFilter}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Page = () => {
|
||||
from: ROUTE_PATHS.Organization.RoleByIDPage.id
|
||||
});
|
||||
const roleId = params.roleId as string;
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data } = useGetOrgRole(orgId, roleId);
|
||||
const { mutateAsync: deleteOrgRole } = useDeleteOrgRole();
|
||||
@@ -91,7 +91,7 @@ export const Page = () => {
|
||||
Roles
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title={data.name}
|
||||
description={
|
||||
<>
|
||||
|
||||
+10
-1
@@ -12,6 +12,7 @@ import {
|
||||
OrgPermissionKmipActions,
|
||||
OrgPermissionMachineIdentityAuthTemplateActions,
|
||||
OrgPermissionSecretShareAction,
|
||||
OrgPermissionSubOrgActions,
|
||||
OrgRelayPermissionActions
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { TPermission } from "@app/hooks/api/roles/types";
|
||||
@@ -123,6 +124,13 @@ const secretSharingPermissionSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const subOrganizationPermissionSchema = z
|
||||
.object({
|
||||
[OrgPermissionSubOrgActions.Create]: z.boolean().optional(),
|
||||
[OrgPermissionSubOrgActions.DirectAccess]: z.boolean().optional()
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string().trim(),
|
||||
description: z.string().trim().optional(),
|
||||
@@ -159,7 +167,8 @@ export const formSchema = z.object({
|
||||
gateway: orgGatewayPermissionSchema,
|
||||
relay: orgRelayPermissionSchema,
|
||||
"machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema,
|
||||
"secret-share": secretSharingPermissionSchema
|
||||
"secret-share": secretSharingPermissionSchema,
|
||||
"sub-organization": subOrganizationPermissionSchema
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
|
||||
import { OrgPermissionSubOrgActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
import { TFormSchema } from "../OrgRoleModifySection.utils";
|
||||
|
||||
const PERMISSION_ACTIONS = [
|
||||
{ action: OrgPermissionSubOrgActions.Create, label: "Create" },
|
||||
{ action: OrgPermissionSubOrgActions.DirectAccess, label: "Direct Access" }
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
isEditable: boolean;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
};
|
||||
|
||||
enum Permission {
|
||||
NoAccess = "no-access",
|
||||
FullAccess = "full-access",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
export const OrgPermissionSubOrgRow = ({ isEditable, control, setValue }: Props) => {
|
||||
const [isRowExpanded, setIsRowExpanded] = useToggle();
|
||||
const [isCustom, setIsCustom] = useToggle();
|
||||
|
||||
const rule = useWatch({
|
||||
control,
|
||||
name: "permissions.sub-organization"
|
||||
});
|
||||
|
||||
const selectedPermissionCategory = useMemo(() => {
|
||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
||||
const totalActions = PERMISSION_ACTIONS.length;
|
||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
||||
|
||||
if (isCustom) return Permission.Custom;
|
||||
if (score === 0) return Permission.NoAccess;
|
||||
if (score === totalActions) return Permission.FullAccess;
|
||||
return Permission.Custom;
|
||||
}, [rule, isCustom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
||||
else setIsCustom.off();
|
||||
}, [selectedPermissionCategory]);
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
if (val === Permission.Custom) {
|
||||
setIsRowExpanded.on();
|
||||
setIsCustom.on();
|
||||
return;
|
||||
}
|
||||
setIsCustom.off();
|
||||
|
||||
switch (val) {
|
||||
case Permission.NoAccess:
|
||||
setValue(
|
||||
"permissions.sub-organization",
|
||||
{
|
||||
[OrgPermissionSubOrgActions.Create]: false,
|
||||
[OrgPermissionSubOrgActions.DirectAccess]: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
"permissions.sub-organization",
|
||||
{
|
||||
[OrgPermissionSubOrgActions.Create]: true,
|
||||
[OrgPermissionSubOrgActions.DirectAccess]: true
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
default:
|
||||
setValue(
|
||||
"permissions.sub-organization",
|
||||
{
|
||||
[OrgPermissionSubOrgActions.Create]: true,
|
||||
[OrgPermissionSubOrgActions.DirectAccess]: true
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
onClick={() => setIsRowExpanded.toggle()}
|
||||
>
|
||||
<Td className="w-4">
|
||||
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
|
||||
</Td>
|
||||
<Td className="w-full select-none">Sub-Organizations</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={selectedPermissionCategory}
|
||||
className="h-8 w-40 bg-mineshaft-700"
|
||||
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={handlePermissionChange}
|
||||
isDisabled={!isEditable}
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
||||
</Select>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isRowExpanded && (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
|
||||
<div className="flex grow flex-wrap justify-start gap-x-8 gap-y-4">
|
||||
{PERMISSION_ACTIONS.map(({ action, label }) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`permissions.sub-organization.${action}`}
|
||||
key={`permissions.sub-organization.${action}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(e) => {
|
||||
if (!isEditable) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update default role"
|
||||
});
|
||||
return;
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
id={`permissions.sub-organization.${action}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -74,6 +74,7 @@ type Props = {
|
||||
| "billing"
|
||||
| "audit-logs"
|
||||
| "machine-identity-auth-template"
|
||||
| "sub-organization"
|
||||
>;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
|
||||
+31
-7
@@ -25,6 +25,7 @@ import { OrgPermissionKmipRow } from "./OrgPermissionKmipRow";
|
||||
import { OrgPermissionMachineIdentityAuthTemplateRow } from "./OrgPermissionMachineIdentityAuthTemplateRow";
|
||||
import { OrgRelayPermissionRow } from "./OrgPermissionRelayRow";
|
||||
import { OrgPermissionSecretShareRow } from "./OrgPermissionSecretShareRow";
|
||||
import { OrgPermissionSubOrgRow } from "./OrgPermissionSubOrgRow";
|
||||
import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow";
|
||||
import { RolePermissionRow } from "./RolePermissionRow";
|
||||
|
||||
@@ -76,8 +77,18 @@ type Props = {
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
const INVALID_SUBORG_PERMISSIONS = [
|
||||
OrgPermissionSubjects.Sso,
|
||||
OrgPermissionSubjects.Ldap,
|
||||
OrgPermissionSubjects.Scim,
|
||||
OrgPermissionSubjects.GithubOrgSync,
|
||||
OrgPermissionSubjects.GithubOrgSyncManual,
|
||||
OrgPermissionSubjects.Billing,
|
||||
OrgPermissionSubjects.SubOrganization
|
||||
];
|
||||
|
||||
export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isRootOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: role } = useGetOrgRole(orgId, roleId);
|
||||
@@ -152,7 +163,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TBody>
|
||||
{SIMPLE_PERMISSION_OPTIONS.map((permission) => {
|
||||
{SIMPLE_PERMISSION_OPTIONS.filter((el) =>
|
||||
isRootOrganization
|
||||
? true
|
||||
: !INVALID_SUBORG_PERMISSIONS.includes(el.formName as OrgPermissionSubjects)
|
||||
).map((permission) => {
|
||||
return (
|
||||
<RolePermissionRow
|
||||
title={permission.title}
|
||||
@@ -194,11 +209,13 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
<OrgPermissionBillingRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
{isRootOrganization && (
|
||||
<OrgPermissionBillingRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
)}
|
||||
<OrgPermissionSecretShareRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
@@ -224,6 +241,13 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
{isRootOrganization && (
|
||||
<OrgPermissionSubOrgRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { ShareSecretSection } from "./ShareSecretSection";
|
||||
|
||||
export const SecretSharingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -22,7 +23,7 @@ export const SecretSharingPage = () => {
|
||||
<div className="h-full">
|
||||
<div className="mx-auto h-full w-full max-w-8xl bg-bunker-800 text-white">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title="Secret Sharing"
|
||||
description="Share secrets securely using a shareable link"
|
||||
>
|
||||
|
||||
@@ -3,17 +3,21 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { RequestSecretTab } from "./components/RequestSecret/RequestSecretTab";
|
||||
import { SecretSharingSettingsTab } from "./components/SecretSharingSettings/SecretSharingSettingsTab";
|
||||
import { ShareSecretTab } from "./components/ShareSecret/ShareSecretTab";
|
||||
|
||||
enum SecretSharingPageTabs {
|
||||
ShareSecret = "share-secret",
|
||||
RequestSecret = "request-secret"
|
||||
RequestSecret = "request-secret",
|
||||
Settings = "settings"
|
||||
}
|
||||
|
||||
export const ShareSecretSection = () => {
|
||||
const navigate = useNavigate();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const { selectedTab } = useSearch({
|
||||
from: ROUTE_PATHS.Organization.SecretSharing.id
|
||||
@@ -26,6 +30,8 @@ export const ShareSecretSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const tabVariant = isSubOrganization ? "namespace" : "org";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
@@ -36,12 +42,17 @@ export const ShareSecretSection = () => {
|
||||
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab variant="org" value={SecretSharingPageTabs.ShareSecret}>
|
||||
<Tab variant={tabVariant} value={SecretSharingPageTabs.ShareSecret}>
|
||||
Share Secrets
|
||||
</Tab>
|
||||
<Tab variant="org" value={SecretSharingPageTabs.RequestSecret}>
|
||||
<Tab variant={tabVariant} value={SecretSharingPageTabs.RequestSecret}>
|
||||
Request Secrets
|
||||
</Tab>
|
||||
{!isSubOrganization && (
|
||||
<Tab variant={tabVariant} value={SecretSharingPageTabs.Settings}>
|
||||
Settings
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel value={SecretSharingPageTabs.ShareSecret}>
|
||||
<ShareSecretTab />
|
||||
@@ -49,6 +60,9 @@ export const ShareSecretSection = () => {
|
||||
<TabPanel value={SecretSharingPageTabs.RequestSecret}>
|
||||
<RequestSecretTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={SecretSharingPageTabs.Settings}>
|
||||
<SecretSharingSettingsTab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
+11
-3
@@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -36,6 +37,10 @@ export const RequestSecretForm = () => {
|
||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||
initialState: "Copy to clipboard"
|
||||
});
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
const { mutateAsync: createSecretRequest } = useCreateSecretRequest();
|
||||
|
||||
@@ -58,12 +63,15 @@ export const RequestSecretForm = () => {
|
||||
expiresAt
|
||||
});
|
||||
|
||||
const link = `${window.location.origin}/secret-request/secret/${id}`;
|
||||
const link = new URL(`${window.location.origin}/secret-request/secret/${id}`);
|
||||
if (subOrganization) {
|
||||
link.searchParams.set("subOrganization", subOrganization);
|
||||
}
|
||||
|
||||
setSecretLink(link);
|
||||
setSecretLink(link.toString());
|
||||
reset();
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
navigator.clipboard.writeText(link.toString());
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
createNotification({
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionSecretShareAction } from "@app/context/OrgPermissionContext/types";
|
||||
import { withPermission } from "@app/hoc";
|
||||
|
||||
import { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
||||
import { SecretSharingAllowShareToAnyone } from "./SecretSharingAllowShareToAnyone";
|
||||
|
||||
export const SecretSharingSettingsTab = withPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SecretSharingAllowShareToAnyone />
|
||||
<OrgSecretShareLimitSection />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: OrgPermissionSecretShareAction.ManageSettings,
|
||||
subject: OrgPermissionSubjects.SecretShare
|
||||
}
|
||||
);
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionSecretShareAction,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { withPermission } from "@app/hoc";
|
||||
|
||||
import { SecretSharingSettingsTabGroup } from "./components";
|
||||
|
||||
export const SecretSharingSettingsPage = withPermission(
|
||||
() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
|
||||
</Helmet>
|
||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader scope="org" title="Secret Share Settings" />
|
||||
<SecretSharingSettingsTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: OrgPermissionSecretShareAction.ManageSettings,
|
||||
subject: OrgPermissionSubjects.SecretShare
|
||||
}
|
||||
);
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { SecretSharingAllowShareToAnyone } from "./SecretSharingAllowShareToAnyone";
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
|
||||
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
|
||||
|
||||
export const SecretSharingSettingsGeneralTab = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SecretSharingAllowShareToAnyone />
|
||||
<OrgSecretShareLimitSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { SecretSharingSettingsGeneralTab } from "./SecretSharingSettingsGeneralTab";
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
|
||||
import { SecretSharingSettingsGeneralTab } from "../SecretSharingSettingsGeneralTab";
|
||||
|
||||
export const SecretSharingSettingsTabGroup = () => {
|
||||
const search = useSearch({
|
||||
from: ROUTE_PATHS.Organization.SecretSharingSettings.id
|
||||
});
|
||||
const tabs = [
|
||||
{
|
||||
name: "General",
|
||||
key: "tab-secret-sharing-general",
|
||||
component: SecretSharingSettingsGeneralTab
|
||||
}
|
||||
];
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
|
||||
|
||||
return (
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabList>
|
||||
{tabs.map((tab) => (
|
||||
<Tab value={tab.key} key={tab.key}>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{tabs.map(({ key, component: Component }) => (
|
||||
<TabPanel value={key} key={`tab-panel-${key}`}>
|
||||
<Component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { SecretSharingSettingsTabGroup } from "./SecretSharingSettingsTabGroup";
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretSharingSettingsTabGroup } from "./SecretSharingSettingsTabGroup";
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingSettingsPage } from "./SecretSharingSettingsPage";
|
||||
|
||||
const SettingsPageQueryParams = z.object({
|
||||
selectedTab: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings"
|
||||
)({
|
||||
component: SecretSharingSettingsPage,
|
||||
validateSearch: zodValidator(SettingsPageQueryParams),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ selectedTab: "" })]
|
||||
},
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Secret Sharing",
|
||||
link: linkOptions({ to: "/organization/secret-sharing" })
|
||||
},
|
||||
{
|
||||
label: "Settings"
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { OrgTabGroup } from "./components";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -16,7 +18,7 @@ export const SettingsPage = () => {
|
||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
description="Configure organization-wide settings"
|
||||
title={t("settings.org.title")}
|
||||
/>
|
||||
|
||||
+9
-6
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -29,6 +30,8 @@ const formSchema = z.object({
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const isUpdate = Boolean(auditLogStream);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
@@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Bearer <token>"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={(e) => {
|
||||
placeholder="Bearer <token>"
|
||||
onFocus={() => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
@@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
) {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur={() => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
@@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
|
||||
) {
|
||||
field.onChange("******");
|
||||
}
|
||||
e.target.type = "password";
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export const OrgDeleteSection = () => {
|
||||
onClick={() => handlePopUpOpen("deleteOrg")}
|
||||
isDisabled={Boolean(!hasOrgRole(OrgMembershipRole.Admin))}
|
||||
>
|
||||
{`Delete ${currentOrg?.name}`}
|
||||
{`Delete ${currentOrg.subOrganization?.name ?? currentOrg?.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
|
||||
+5
-4
@@ -1,16 +1,17 @@
|
||||
import { useOrgPermission } from "@app/context";
|
||||
import { useOrganization, useOrgPermission } from "@app/context";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
|
||||
import { OrgDeleteSection } from "../OrgDeleteSection";
|
||||
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
|
||||
import { OrgNameChangeSection } from "../OrgNameChangeSection";
|
||||
import { OrgNameChangeSection, SubOrgNameChangeSection } from "../OrgNameChangeSection";
|
||||
|
||||
export const OrgGeneralTab = () => {
|
||||
const { hasOrgRole } = useOrgPermission();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<OrgNameChangeSection />
|
||||
<OrgIncidentContactsSection />
|
||||
{isSubOrganization ? <SubOrgNameChangeSection /> : <OrgNameChangeSection />}
|
||||
{!isSubOrganization && <OrgIncidentContactsSection />}
|
||||
{hasOrgRole(OrgMembershipRole.Admin) && <OrgDeleteSection />}
|
||||
</div>
|
||||
);
|
||||
|
||||
+13
-15
@@ -41,21 +41,19 @@ export const OrgNameChangeSection = (): JSX.Element => {
|
||||
const [isFormInitialized, setIsFormInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentOrg) {
|
||||
reset({
|
||||
name: currentOrg.name,
|
||||
slug: currentOrg.slug,
|
||||
...(canReadOrgRoles &&
|
||||
roles?.length && {
|
||||
// will always be present, can't remove role if default
|
||||
defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole)
|
||||
? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug || ""
|
||||
: currentOrg.defaultMembershipRole
|
||||
})
|
||||
});
|
||||
setIsFormInitialized(true);
|
||||
}
|
||||
}, [currentOrg, roles]);
|
||||
reset({
|
||||
name: currentOrg.name,
|
||||
slug: currentOrg.slug,
|
||||
...(canReadOrgRoles &&
|
||||
roles?.length && {
|
||||
// will always be present, can't remove role if default
|
||||
defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole)
|
||||
? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug || ""
|
||||
: currentOrg.defaultMembershipRole
|
||||
})
|
||||
});
|
||||
setIsFormInitialized(true);
|
||||
}, [roles]);
|
||||
|
||||
const onFormSubmit = async ({ name, slug, defaultMembershipRole }: FormData) => {
|
||||
try {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useOrgPermission
|
||||
} from "@app/context";
|
||||
import { useUpdateSubOrganization } from "@app/hooks/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const SubOrgNameChangeSection = (): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { permission } = useOrgPermission();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { handleSubmit, control } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: currentOrg?.subOrganization?.name || ""
|
||||
}
|
||||
});
|
||||
const { mutateAsync, isPending } = useUpdateSubOrganization();
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
name,
|
||||
subOrgId: currentOrg.id
|
||||
});
|
||||
|
||||
navigate({ to: "/organization/settings", search: { subOrganization: name } });
|
||||
queryClient.invalidateQueries();
|
||||
await router.invalidate({ sync: true });
|
||||
createNotification({
|
||||
text: "Successfully updated sub-organization details",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update sub-organization details",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="py-4">
|
||||
<div>
|
||||
<h2 className="text-md mb-2 text-mineshaft-100">Organization Name</h2>
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} className="max-w-md">
|
||||
<Input
|
||||
isDisabled={permission.cannot(
|
||||
OrgPermissionActions.Edit,
|
||||
OrgPermissionSubjects.Settings
|
||||
)}
|
||||
placeholder="Acme Corp"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||
export { SubOrgNameChangeSection } from "./SubOrgNameChangeSection";
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ export const OrgProductSelectSection = () => {
|
||||
}));
|
||||
}
|
||||
});
|
||||
}, [currentOrg]);
|
||||
}, [currentOrg?.id]);
|
||||
|
||||
const onProductToggle = async (value: boolean, key: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
+32
-14
@@ -3,6 +3,7 @@ import { useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
|
||||
import { ExternalMigrationsTab } from "../ExternalMigrationsTab";
|
||||
@@ -19,20 +20,33 @@ export const OrgTabGroup = () => {
|
||||
const search = useSearch({
|
||||
from: ROUTE_PATHS.Organization.SettingsPage.id
|
||||
});
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const tabs = [
|
||||
{ name: "General", key: "tab-org-general", component: OrgGeneralTab },
|
||||
{
|
||||
name: "SSO",
|
||||
key: "sso-settings",
|
||||
component: OrgSsoTab
|
||||
component: OrgSsoTab,
|
||||
isHidden: isSubOrganization
|
||||
},
|
||||
{
|
||||
name: "Provisioning",
|
||||
key: "provisioning-settings",
|
||||
component: OrgProvisioningTab
|
||||
component: OrgProvisioningTab,
|
||||
isHidden: isSubOrganization
|
||||
},
|
||||
{
|
||||
name: "Security",
|
||||
key: "tab-org-security",
|
||||
component: OrgSecurityTab,
|
||||
isHidden: isSubOrganization
|
||||
},
|
||||
{
|
||||
name: "Encryption",
|
||||
key: "tab-org-encryption",
|
||||
component: OrgEncryptionTab
|
||||
},
|
||||
{ name: "Security", key: "tab-org-security", component: OrgSecurityTab },
|
||||
{ name: "Encryption", key: "tab-org-encryption", component: OrgEncryptionTab },
|
||||
{
|
||||
name: "Workflow Integrations",
|
||||
key: "workflow-integrations",
|
||||
@@ -57,17 +71,21 @@ export const OrgTabGroup = () => {
|
||||
return (
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabList>
|
||||
{tabs.map((tab) => (
|
||||
<Tab variant="org" value={tab.key} key={tab.key}>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
{tabs
|
||||
.filter((el) => !el.isHidden)
|
||||
.map((tab) => (
|
||||
<Tab variant={isSubOrganization ? "namespace" : "org"} value={tab.key} key={tab.key}>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{tabs.map(({ key, component: Component }) => (
|
||||
<TabPanel value={key} key={`tab-panel-${key}`}>
|
||||
<Component />
|
||||
</TabPanel>
|
||||
))}
|
||||
{tabs
|
||||
.filter((el) => !el.isHidden)
|
||||
.map(({ key, component: Component }) => (
|
||||
<TabPanel value={key} key={`tab-panel-${key}`}>
|
||||
<Component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
+118
-144
@@ -1,6 +1,5 @@
|
||||
import { faPlus, faTrash, faUnlock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
@@ -70,154 +69,129 @@ export const ProjectTemplateRolesSection = ({ projectTemplate, isInfisicalTempla
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<AnimatePresence>
|
||||
{popUp?.editRole.isOpen ? (
|
||||
<motion.div
|
||||
key="edit-role"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="absolute min-h-40 w-full"
|
||||
>
|
||||
<ProjectTemplateEditRoleForm
|
||||
onGoBack={() => handlePopUpClose("editRole")}
|
||||
projectTemplate={projectTemplate}
|
||||
role={editRole}
|
||||
isDisabled={
|
||||
permission.cannot(
|
||||
OrgPermissionActions.Edit,
|
||||
OrgPermissionSubjects.ProjectTemplates
|
||||
) ||
|
||||
(editRole && !isCustomProjectRole(editRole.slug))
|
||||
}
|
||||
/>
|
||||
<div className="h-4 w-full" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="role-list"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 0 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
className="absolute w-full"
|
||||
>
|
||||
<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">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">Project Roles</h2>
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
{isInfisicalTemplate
|
||||
? "Click a role to view the associated permissions"
|
||||
: "Add, edit and remove roles for this project template"}
|
||||
</p>
|
||||
</div>
|
||||
{!isInfisicalTemplate && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
{popUp?.editRole.isOpen ? (
|
||||
<ProjectTemplateEditRoleForm
|
||||
onGoBack={() => handlePopUpClose("editRole")}
|
||||
projectTemplate={projectTemplate}
|
||||
role={editRole}
|
||||
isDisabled={
|
||||
permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates) ||
|
||||
(editRole && !isCustomProjectRole(editRole.slug))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">Project Roles</h2>
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
{isInfisicalTemplate
|
||||
? "Click a role to view the associated permissions"
|
||||
: "Add, edit and remove roles for this project template"}
|
||||
</p>
|
||||
</div>
|
||||
{!isInfisicalTemplate && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("editRole");
|
||||
}}
|
||||
colorSchema="primary"
|
||||
className="ml-auto"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("editRole");
|
||||
}}
|
||||
colorSchema="primary"
|
||||
className="ml-auto"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{roles.length ? (
|
||||
roles.map((role) => {
|
||||
return (
|
||||
<Tr
|
||||
key={role.slug}
|
||||
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handlePopUpOpen("editRole", role);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePopUpOpen("editRole", role)}
|
||||
>
|
||||
<Td>{role.name}</Td>
|
||||
<Td>{role.slug}</Td>
|
||||
<Td>
|
||||
{isCustomProjectRole(role.slug) && (
|
||||
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{roles.length ? (
|
||||
roles.map((role) => {
|
||||
return (
|
||||
<Tr
|
||||
key={role.slug}
|
||||
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handlePopUpOpen("editRole", role);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePopUpOpen("editRole", role)}
|
||||
>
|
||||
<Td>{role.name}</Td>
|
||||
<Td>{role.slug}</Td>
|
||||
<Td>
|
||||
{isCustomProjectRole(role.slug) && (
|
||||
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete-icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handlePopUpOpen("removeRole", role);
|
||||
}}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete-icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handlePopUpOpen("removeRole", role);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<EmptyState title="No roles assigned to template" icon={faUnlock} />
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeRole.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Are you sure you want to remove the role ${roleToDelete?.slug}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeRole", isOpen)}
|
||||
onDeleteApproved={() => handleRemoveRole(roleToDelete?.slug)}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-4 w-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<EmptyState title="No roles assigned to template" icon={faUnlock} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeRole.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Are you sure you want to remove the role ${roleToDelete?.slug}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeRole", isOpen)}
|
||||
onDeleteApproved={() => handleRemoveRole(roleToDelete?.slug)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+62
-86
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
@@ -25,93 +24,70 @@ export const ProjectTemplatesSection = () => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<AnimatePresence>
|
||||
{editTemplate ? (
|
||||
<motion.div
|
||||
key="edit-project-template"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="absolute min-h-40 w-full"
|
||||
>
|
||||
<EditProjectTemplateSection
|
||||
template={editTemplate}
|
||||
onBack={() => setEditTemplate(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="project-templates-list"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="absolute min-h-40 w-full"
|
||||
>
|
||||
<div>
|
||||
<p className="mb-6 font-inter text-bunker-300">
|
||||
Create and configure templates with predefined roles and environments to streamline
|
||||
project setup
|
||||
</p>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-start">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/project-templates"
|
||||
>
|
||||
<div className="mt-[0.32rem] ml-2 inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Create}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (!subscription?.projectTemplates) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addTemplate");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
className="ml-auto"
|
||||
>
|
||||
Add Template
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{editTemplate ? (
|
||||
<EditProjectTemplateSection template={editTemplate} onBack={() => setEditTemplate(null)} />
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-6 font-inter text-bunker-300">
|
||||
Create and configure templates with predefined roles and environments to streamline
|
||||
project setup
|
||||
</p>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-start">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/project-templates"
|
||||
>
|
||||
<div className="mt-[0.32rem] ml-2 inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<ProjectTemplatesTable onEdit={setEditTemplate} />
|
||||
<ProjectTemplateDetailsModal
|
||||
onComplete={(template) => setEditTemplate(template)}
|
||||
isOpen={popUp.addTemplate.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addTemplate", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can create project templates if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Create}
|
||||
a={OrgPermissionSubjects.ProjectTemplates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (!subscription?.projectTemplates) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addTemplate");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
className="ml-auto"
|
||||
>
|
||||
Add Template
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ProjectTemplatesTable onEdit={setEditTemplate} />
|
||||
<ProjectTemplateDetailsModal
|
||||
onComplete={(template) => setEditTemplate(template)}
|
||||
isOpen={popUp.addTemplate.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addTemplate", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can create project templates if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = withPermission(
|
||||
});
|
||||
const membershipId = search.membershipId as string;
|
||||
const { user } = useUser();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
@@ -131,7 +131,7 @@ const Page = withPermission(
|
||||
Users
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope="org"
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title={
|
||||
membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
@@ -140,7 +140,7 @@ const Page = withPermission(
|
||||
membership.inviteEmail ??
|
||||
"Unknown User")
|
||||
}
|
||||
description="Organization User Membership"
|
||||
description={`${isSubOrganization ? "Sub-" : ""}Organization User Membership`}
|
||||
>
|
||||
<div>
|
||||
{userId !== membership.user.id && (
|
||||
|
||||
+3
-2
@@ -35,7 +35,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
});
|
||||
|
||||
const { user } = useUser();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
@@ -214,7 +214,8 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
<p className="text-sm text-mineshaft-300">-</p>
|
||||
)}
|
||||
</div>
|
||||
{membership.isActive &&
|
||||
{!isSubOrganization &&
|
||||
membership.isActive &&
|
||||
(membership.status === "invited" || membership.status === "verified") &&
|
||||
membership.user.email &&
|
||||
serverDetails?.emailConfigured && (
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, retainSearchParams } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OrganizationLayout } from "@app/layouts/OrganizationLayout";
|
||||
|
||||
export const Route = createFileRoute("/_authenticate/_inject-org-details/_org-layout")({
|
||||
component: OrganizationLayout
|
||||
component: OrganizationLayout,
|
||||
validateSearch: z.object({
|
||||
subOrganization: z.string().optional()
|
||||
}),
|
||||
search: {
|
||||
middlewares: [retainSearchParams(["subOrganization"])]
|
||||
}
|
||||
});
|
||||
|
||||
+15
-5
@@ -34,10 +34,11 @@ const CreateForm = ({
|
||||
}: CreateFormProps) => {
|
||||
const createPamAccount = useCreatePamAccount();
|
||||
|
||||
console.log({ folderId });
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const account = await createPamAccount.mutateAsync({
|
||||
@@ -64,7 +65,13 @@ const CreateForm = ({
|
||||
|
||||
switch (resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm onSubmit={onSubmit} />;
|
||||
return (
|
||||
<PostgresAccountForm
|
||||
onSubmit={onSubmit}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -74,7 +81,10 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
const updatePamAccount = useUpdatePamAccount();
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const updatedAccount = await updatePamAccount.mutateAsync({
|
||||
|
||||
+24
-4
@@ -1,26 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { TPostgresAccount } from "@app/hooks/api/pam";
|
||||
import { PamResourceType, TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TPostgresAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
|
||||
credentials: BaseSqlAccountSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
@@ -30,7 +35,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
...account,
|
||||
credentials: {
|
||||
...account.credentials,
|
||||
password: "******"
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
@@ -41,6 +46,20 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false);
|
||||
|
||||
const { data: resource } = useGetPamResourceById(resourceType, resourceId, {
|
||||
enabled: !account && !!resourceId && !!resourceType
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured);
|
||||
} else {
|
||||
setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials);
|
||||
}
|
||||
}, [account, resource]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
@@ -50,6 +69,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
|
||||
|
||||
export const rotateAccountFieldsSchema = z.object({
|
||||
rotationEnabled: z.boolean(),
|
||||
rotationIntervalSeconds: z.number().nullable().optional()
|
||||
});
|
||||
|
||||
export const RotateAccountFields = ({
|
||||
rotationCredentialsConfigured
|
||||
}: {
|
||||
rotationCredentialsConfigured: boolean;
|
||||
}) => {
|
||||
const { control, watch } = useFormContext<{
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
}>();
|
||||
|
||||
const rotationEnabled = watch("rotationEnabled");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={rotationCredentialsConfigured}
|
||||
content="The resource which owns this account does not have rotation credentials configured."
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-9 w-fit items-center gap-3",
|
||||
!rotationCredentialsConfigured && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="rotationEnabled"
|
||||
defaultValue={false}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
|
||||
<Switch
|
||||
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="rotation-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
isDisabled={!rotationCredentialsConfigured}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">Rotate Credentials Every</span>
|
||||
|
||||
<Controller
|
||||
name="rotationIntervalSeconds"
|
||||
control={control}
|
||||
defaultValue={2592000}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
value={(value || 2592000).toString()}
|
||||
onValueChange={(val) => onChange(parseInt(val, 10))}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
placeholder="Select an interval..."
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isDisabled={!rotationEnabled || !rotationCredentialsConfigured}
|
||||
dropdownContainerStyle={{
|
||||
width: "130px"
|
||||
}}
|
||||
>
|
||||
<SelectItem value="2592000">30 Days</SelectItem>
|
||||
<SelectItem value="604800">7 Days</SelectItem>
|
||||
<SelectItem value="259200">3 Days</SelectItem>
|
||||
<SelectItem value="86400">1 Day</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+20
-9
@@ -1,9 +1,19 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control } = useFormContext();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const password = useWatch({ control, name: "credentials.password" });
|
||||
|
||||
useEffect(() => {
|
||||
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
@@ -17,7 +27,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<Input {...field} />
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -33,18 +43,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
onFocus={(e) => {
|
||||
if (isUpdate && field.value === "******") {
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur={() => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange("******");
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
e.target.type = "password";
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
+6
-2
@@ -5,6 +5,10 @@ export const BaseSqlAccountSchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username required")
|
||||
.max(255, "Username must be 255 characters or less"),
|
||||
password: z.string().trim().min(1, "Password required")
|
||||
.max(63, "Username must be 63 characters or less"),
|
||||
password: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Password required")
|
||||
.max(256, "Password must be 256 characters or less")
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
faEllipsisV,
|
||||
faFolder,
|
||||
faRightToBracket,
|
||||
faRotate,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -100,6 +102,12 @@ export const PamAccountRow = ({
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{account.lastRotatedAt && (
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 bg-orange/20 whitespace-nowrap text-orange">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
<span>Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
+27
-12
@@ -5,9 +5,12 @@ import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas";
|
||||
|
||||
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
|
||||
import { SqlResourceFields } from "./shared/SqlResourceFields";
|
||||
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
|
||||
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +20,8 @@ type Props = {
|
||||
|
||||
const formSchema = genericResourceFieldsSchema.extend({
|
||||
resourceType: z.literal(PamResourceType.Postgres),
|
||||
connectionDetails: BaseSqlResourceSchema
|
||||
connectionDetails: BaseSqlResourceSchema,
|
||||
rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.Postgres,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "default",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
defaultValues: resource
|
||||
? {
|
||||
...resource,
|
||||
rotationAccountCredentials: resource.rotationAccountCredentials
|
||||
? {
|
||||
...resource.rotationAccountCredentials,
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
}
|
||||
: resource.rotationAccountCredentials
|
||||
}
|
||||
: {
|
||||
resourceType: PamResourceType.Postgres,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "default",
|
||||
sslEnabled: true,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
<SqlRotateAccountFields isUpdate={isUpdate} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
FormControl,
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
|
||||
export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control } = useFormContext();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const password = useWatch({ control, name: "credentials.password" });
|
||||
|
||||
useEffect(() => {
|
||||
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
<div className="order-1 ml-3">Rotation Account</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent childrenClassName="px-0 py-0">
|
||||
<p className="mb-2 text-xs">
|
||||
Credentials of the privileged account which will be used for rotating other accounts
|
||||
under this resource
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
name="rotationAccountCredentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0 flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="rotationAccountCredentials.password"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0 flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
+36
-2
@@ -1,11 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { components, OptionProps } from "react-select";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faGlobe } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
@@ -24,7 +29,7 @@ import {
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
identity: z.object({ name: z.string(), id: z.string() }),
|
||||
identity: z.object({ name: z.string(), id: z.string(), isManagedByRootOrg: z.boolean() }),
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
@@ -35,6 +40,29 @@ type Props = {
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const Option = ({
|
||||
isSelected,
|
||||
children,
|
||||
...props
|
||||
}: OptionProps<{ name: string; id: string; isManagedByRootOrg: boolean }>) => {
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="truncate">{children}</p>
|
||||
{props.data.isManagedByRootOrg && (
|
||||
<Badge variant="org" className="ml-auto cursor-pointer">
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
Managed by Root Org
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { projectId } = useProject();
|
||||
@@ -131,9 +159,15 @@ const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select identity..."
|
||||
options={filteredIdentityMembershipOrgs.map((membership) => membership.identity)}
|
||||
options={filteredIdentityMembershipOrgs.map((membership) => ({
|
||||
...membership.identity,
|
||||
isManagedByRootOrg: membership.identity.orgId !== currentOrg.id
|
||||
}))}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
components={{
|
||||
Option
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ErrorPage = ({ error }: ErrorComponentProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex h-screen items-center justify-center bg-bunker-800">
|
||||
<div className="flex max-w-3xl flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-8 text-center text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faBugs} className="my-2 inline text-6xl" />
|
||||
<p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -87,6 +88,10 @@ export const ShareSecretForm = ({
|
||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||
initialState: "Copy to clipboard"
|
||||
});
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
const publicSharedSecretCreator = useCreatePublicSharedSecret();
|
||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||
@@ -148,11 +153,14 @@ export const ShareSecretForm = ({
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
const link = `${window.location.origin}/shared/secret/${id}`;
|
||||
const link = new URL(`${window.location.origin}/shared/secret/${id}`);
|
||||
if (subOrganization) {
|
||||
link.searchParams.set("subOrganization", subOrganization);
|
||||
}
|
||||
|
||||
setSecretLink(link);
|
||||
setSecretLink(link.toString());
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
navigator.clipboard.writeText(link.toString());
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
createNotification({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
import { createRootRouteWithContext, Outlet, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { NotificationContainer } from "@app/components/notifications";
|
||||
import { TooltipProvider } from "@app/components/v2";
|
||||
@@ -13,6 +14,15 @@ type TRouterContext = {
|
||||
};
|
||||
|
||||
const RootPage = () => {
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.invalidateQueries();
|
||||
}, [subOrganization]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -1109,7 +1109,6 @@ const Page = () => {
|
||||
secretPath={secretPath}
|
||||
isSmaller={isNotEmpty}
|
||||
environments={currentProject?.environments}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
<PitDrawer
|
||||
secretSnaphots={snapshotList}
|
||||
|
||||
+117
-154
@@ -10,7 +10,6 @@ import {
|
||||
faUpload
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -34,21 +33,27 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||
import {
|
||||
dashboardKeys,
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { fetchProjectSecrets, mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
|
||||
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
|
||||
import {
|
||||
BatchContext,
|
||||
PendingSecretCreate,
|
||||
PendingSecretUpdate,
|
||||
PopUpNames,
|
||||
useBatchModeActions,
|
||||
usePopUpAction
|
||||
} from "../../SecretMainPage.store";
|
||||
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
|
||||
import { PasteSecretEnvModal } from "./PasteSecretEnvModal";
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
type TSecOverwriteOpt = {
|
||||
update: TParsedEnv;
|
||||
create: TParsedEnv;
|
||||
existingSecrets: SecretV3RawSanitized[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
@@ -56,7 +61,6 @@ type Props = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
isProtectedBranch?: boolean;
|
||||
};
|
||||
|
||||
type SecretMatrixMap = {
|
||||
@@ -65,7 +69,7 @@ type SecretMatrixMap = {
|
||||
comment: number | null;
|
||||
};
|
||||
|
||||
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const;
|
||||
const popupKeys = ["importSecEnv", "pasteSecEnv", "importMatrixMap"] as const;
|
||||
|
||||
const MatrixImportModalTableRow = ({
|
||||
importSecretMatrixMap,
|
||||
@@ -142,8 +146,7 @@ export const SecretDropzone = ({
|
||||
environments = [],
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
isProtectedBranch = false
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
@@ -157,18 +160,11 @@ export const SecretDropzone = ({
|
||||
});
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
|
||||
const queryClient = useQueryClient();
|
||||
const { openPopUp } = usePopUpAction();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
// hide copy secrets from board due to import folders feature
|
||||
const shouldRenderCopySecrets = false;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -180,6 +176,81 @@ export const SecretDropzone = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async (data: TSecOverwriteOpt) => {
|
||||
const { update, create, existingSecrets } = data;
|
||||
|
||||
try {
|
||||
const context: BatchContext = {
|
||||
projectId,
|
||||
environment,
|
||||
secretPath
|
||||
};
|
||||
|
||||
const existingSecretsMap = existingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
const totalCount = Object.keys(create || {}).length + Object.keys(update || {}).length;
|
||||
|
||||
if (Object.keys(create || {}).length) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
const createChange: PendingSecretCreate = {
|
||||
id: secretKey,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
type: PendingAction.Create,
|
||||
secretKey,
|
||||
secretValue: secData.value,
|
||||
secretComment: secData.comments.join("\n") || undefined,
|
||||
tags: [],
|
||||
secretMetadata: []
|
||||
};
|
||||
addPendingChange(createChange, context);
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
const existingSecret = existingSecretsMap[secretKey];
|
||||
|
||||
if (!existingSecret) {
|
||||
console.warn(`Existing secret not found for key: ${secretKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateChange: PendingSecretUpdate = {
|
||||
id: existingSecret.id,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
type: PendingAction.Update,
|
||||
secretKey,
|
||||
secretValue: secData.value,
|
||||
secretComment: secData.comments.join("\n") || undefined,
|
||||
existingSecret,
|
||||
originalValue: existingSecret.value || "",
|
||||
originalComment: existingSecret.comment || "",
|
||||
originalSkipMultilineEncoding: existingSecret.skipMultilineEncoding || false,
|
||||
originalTags: existingSecret.tags || [],
|
||||
originalSecretMetadata: existingSecret.secretMetadata || []
|
||||
};
|
||||
addPendingChange(updateChange, context);
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully imported ${totalCount} secret${totalCount > 1 ? "s" : ""}.`
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to import secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleParsedEnv = async (env: TParsedEnv) => {
|
||||
const envSecretKeys = Object.keys(env);
|
||||
|
||||
@@ -193,29 +264,38 @@ export const SecretDropzone = ({
|
||||
|
||||
try {
|
||||
setIsLoading.on();
|
||||
const { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({
|
||||
secretPath,
|
||||
environment,
|
||||
const { secrets: rawExistingSecrets } = await fetchProjectSecrets({
|
||||
projectId,
|
||||
keys: envSecretKeys
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue: true
|
||||
});
|
||||
|
||||
const secretsGroupedByKey = existingSecrets.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.secretKey]: true }),
|
||||
const allExistingSecrets = mergePersonalSecrets(rawExistingSecrets);
|
||||
|
||||
const existingSecretsMap = allExistingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
const updateSecrets = Object.keys(env)
|
||||
.filter((secKey) => secretsGroupedByKey[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
const updateSecrets: TParsedEnv = {};
|
||||
const createSecrets: TParsedEnv = {};
|
||||
const relevantExistingSecrets: SecretV3RawSanitized[] = [];
|
||||
|
||||
const createSecrets = Object.keys(env)
|
||||
.filter((secKey) => !secretsGroupedByKey[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
Object.entries(env).forEach(([secretKey, secretData]) => {
|
||||
const existingSecret = existingSecretsMap[secretKey];
|
||||
if (existingSecret) {
|
||||
updateSecrets[secretKey] = secretData;
|
||||
relevantExistingSecrets.push(existingSecret);
|
||||
} else {
|
||||
createSecrets[secretKey] = secretData;
|
||||
}
|
||||
});
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
await handleSaveSecrets({
|
||||
update: updateSecrets,
|
||||
create: createSecrets
|
||||
create: createSecrets,
|
||||
existingSecrets: relevantExistingSecrets
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -223,7 +303,6 @@ export const SecretDropzone = ({
|
||||
text: "Failed to check for secret conflicts",
|
||||
type: "error"
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
@@ -328,70 +407,6 @@ export const SecretDropzone = ({
|
||||
parseFile(e.target?.files?.[0]);
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async () => {
|
||||
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
if (Object.keys(create || {}).length) {
|
||||
await createSecretBatch({
|
||||
secretPath,
|
||||
projectId,
|
||||
environment,
|
||||
secrets: Object.entries(create).map(([secretKey, secData]) => ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (Object.keys(update || {}).length) {
|
||||
await updateSecretBatch({
|
||||
secretPath,
|
||||
projectId,
|
||||
environment,
|
||||
secrets: Object.entries(update).map(([secretKey, secData]) => ({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
}))
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ projectId })
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: isProtectedBranch
|
||||
? "Uploaded changes have been sent for review"
|
||||
: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
|
||||
).length;
|
||||
|
||||
const updateSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
const isNonConflictingUpload = !updateSecretCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -495,58 +510,6 @@ export const SecretDropzone = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.confirmUpload?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Confirm Secret Upload"
|
||||
footerContent={[
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveSecrets}
|
||||
>
|
||||
{isNonConflictingUpload ? "Upload" : "Overwrite"}
|
||||
</Button>,
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpClose("confirmUpload")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isNonConflictingUpload ? (
|
||||
<div>
|
||||
Are you sure you want to import {createSecretCount} secret
|
||||
{createSecretCount > 1 ? "s" : ""} to this environment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col text-gray-300">
|
||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
Are you sure you want to overwrite these secrets
|
||||
{createSecretCount > 0
|
||||
? ` and import ${createSecretCount} new
|
||||
one${createSecretCount > 1 ? "s" : ""}`
|
||||
: ""}
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Matrix Import Modal */}
|
||||
<Modal
|
||||
|
||||
+2
-1
@@ -130,7 +130,8 @@ export const SecretImportListView = ({
|
||||
const [items, setItems] = useState(secretImports ?? []);
|
||||
|
||||
const getImportReplicatedFolder = (importPath: string) => {
|
||||
const cleanImportPath = importPath.replace("/__reserve_replication_", "");
|
||||
if (!importPath.includes("/__reserve_replication_")) return undefined;
|
||||
const cleanImportPath = importPath.split("/__reserve_replication_")[1];
|
||||
const replicatedFolder = items?.find(({ id }) => id === cleanImportPath);
|
||||
return replicatedFolder;
|
||||
};
|
||||
|
||||
@@ -56,7 +56,6 @@ import { Route as organizationAccessManagementPageRouteImport } from './pages/or
|
||||
import { Route as adminGeneralPageRouteImport } from './pages/admin/GeneralPage/route'
|
||||
import { Route as secretManagerRedirectsRedirectApprovalPageImport } from './pages/secret-manager/redirects/redirect-approval-page'
|
||||
import { Route as adminResourceOverviewPageRouteImport } from './pages/admin/ResourceOverviewPage/route'
|
||||
import { Route as organizationSecretSharingSettingsPageRouteImport } from './pages/organization/SecretSharingSettingsPage/route'
|
||||
import { Route as organizationRoleByIDPageRouteImport } from './pages/organization/RoleByIDPage/route'
|
||||
import { Route as organizationUserDetailsByIDPageRouteImport } from './pages/organization/UserDetailsByIDPage/route'
|
||||
import { Route as organizationIdentityDetailsByIDPageRouteImport } from './pages/organization/IdentityDetailsByIDPage/route'
|
||||
@@ -752,14 +751,6 @@ const adminResourceOverviewPageRouteRoute =
|
||||
getParentRoute: () => adminLayoutRoute,
|
||||
} as any)
|
||||
|
||||
const organizationSecretSharingSettingsPageRouteRoute =
|
||||
organizationSecretSharingSettingsPageRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () =>
|
||||
AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute,
|
||||
} as any)
|
||||
|
||||
const organizationRoleByIDPageRouteRoute =
|
||||
organizationRoleByIDPageRouteImport.update({
|
||||
id: '/roles/$roleId',
|
||||
@@ -2625,13 +2616,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof organizationRoleByIDPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings': {
|
||||
id: '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/organization/secret-sharing/settings'
|
||||
preLoaderRoute: typeof organizationSecretSharingSettingsPageRouteImport
|
||||
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': {
|
||||
id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview'
|
||||
path: '/resources/overview'
|
||||
@@ -4033,15 +4017,12 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationNetworkingRouteWithChildr
|
||||
|
||||
interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteChildren {
|
||||
organizationSecretSharingPageRouteRoute: typeof organizationSecretSharingPageRouteRoute
|
||||
organizationSecretSharingSettingsPageRouteRoute: typeof organizationSecretSharingSettingsPageRouteRoute
|
||||
}
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteChildren: AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteChildren =
|
||||
{
|
||||
organizationSecretSharingPageRouteRoute:
|
||||
organizationSecretSharingPageRouteRoute,
|
||||
organizationSecretSharingSettingsPageRouteRoute:
|
||||
organizationSecretSharingSettingsPageRouteRoute,
|
||||
}
|
||||
|
||||
const AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren =
|
||||
@@ -5094,7 +5075,6 @@ export interface FileRoutesByFullPath {
|
||||
'/organization/identities/$identityId': typeof organizationIdentityDetailsByIDPageRouteRoute
|
||||
'/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
|
||||
'/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
|
||||
'/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
|
||||
'/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute
|
||||
'/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren
|
||||
'/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren
|
||||
@@ -5328,7 +5308,6 @@ export interface FileRoutesByTo {
|
||||
'/organization/identities/$identityId': typeof organizationIdentityDetailsByIDPageRouteRoute
|
||||
'/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
|
||||
'/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
|
||||
'/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
|
||||
'/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute
|
||||
'/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren
|
||||
'/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren
|
||||
@@ -5568,7 +5547,6 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/identities/$identityId': typeof organizationIdentityDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': typeof adminResourceOverviewPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsKmsProjectIdRouteWithChildren
|
||||
@@ -5818,7 +5796,6 @@ export interface FileRouteTypes {
|
||||
| '/organization/identities/$identityId'
|
||||
| '/organization/members/$membershipId'
|
||||
| '/organization/roles/$roleId'
|
||||
| '/organization/secret-sharing/settings'
|
||||
| '/admin/resources/overview'
|
||||
| '/projects/cert-management/$projectId'
|
||||
| '/projects/kms/$projectId'
|
||||
@@ -6051,7 +6028,6 @@ export interface FileRouteTypes {
|
||||
| '/organization/identities/$identityId'
|
||||
| '/organization/members/$membershipId'
|
||||
| '/organization/roles/$roleId'
|
||||
| '/organization/secret-sharing/settings'
|
||||
| '/admin/resources/overview'
|
||||
| '/projects/cert-management/$projectId'
|
||||
| '/projects/kms/$projectId'
|
||||
@@ -6289,7 +6265,6 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/identities/$identityId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId'
|
||||
@@ -6814,8 +6789,7 @@ export const routeTree = rootRoute
|
||||
"filePath": "",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization",
|
||||
"children": [
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings"
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/"
|
||||
]
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/settings": {
|
||||
@@ -6865,10 +6839,6 @@ export const routeTree = rootRoute
|
||||
"filePath": "organization/RoleByIDPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings": {
|
||||
"filePath": "organization/SecretSharingSettingsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview": {
|
||||
"filePath": "admin/ResourceOverviewPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
|
||||
|
||||
@@ -21,10 +21,7 @@ const organizationRoutes = route("/organization", [
|
||||
route("/access-management", "organization/AccessManagementPage/route.tsx"),
|
||||
route("/audit-logs", "organization/AuditLogsPage/route.tsx"),
|
||||
route("/billing", "organization/BillingPage/route.tsx"),
|
||||
route("/secret-sharing", [
|
||||
index("organization/SecretSharingPage/route.tsx"),
|
||||
route("/settings", "organization/SecretSharingSettingsPage/route.tsx")
|
||||
]),
|
||||
route("/secret-sharing", [index("organization/SecretSharingPage/route.tsx")]),
|
||||
route("/settings", [
|
||||
index("organization/SettingsPage/route.tsx"),
|
||||
route("/oauth/callback", "organization/SettingsPage/OauthCallbackPage/route.tsx")
|
||||
|
||||
Reference in New Issue
Block a user