mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-13 08:29:28 +00:00
Merge remote-tracking branch 'origin/main' into ENG-3546
This commit is contained in:
@@ -60,7 +60,8 @@ export const CreatableSelect = <T,>({
|
||||
isSelected && "text-mineshaft-200",
|
||||
"px-3 py-2 text-xs hover:cursor-pointer"
|
||||
),
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
|
||||
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -91,7 +91,7 @@ export const FilterableSelect = <T,>({
|
||||
),
|
||||
placeholder: () =>
|
||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||
input: () => "pl-1",
|
||||
input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`,
|
||||
valueContainer: () =>
|
||||
`px-1 max-h-[8.2rem] ${
|
||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||
@@ -114,7 +114,8 @@ export const FilterableSelect = <T,>({
|
||||
isSelected && "text-mineshaft-200",
|
||||
"rounded px-3 py-2 text-xs hover:cursor-pointer"
|
||||
),
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
|
||||
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ export enum OrgPermissionSubjects {
|
||||
Gateway = "gateway",
|
||||
SecretShare = "secret-share",
|
||||
GithubOrgSync = "github-org-sync",
|
||||
GithubOrgSyncManual = "github-org-sync-manual",
|
||||
MachineIdentityAuthTemplate = "machine-identity-auth-template"
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { Organization } from "@app/hooks/api/organization/types";
|
||||
|
||||
import { organizationKeys } from "../organization/queries";
|
||||
import { User } from "../users/types";
|
||||
@@ -8,7 +9,9 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
|
||||
import {
|
||||
RootKeyEncryptionStrategy,
|
||||
TCreateAdminUserDTO,
|
||||
TCreateOrganizationDTO,
|
||||
TInvalidateCacheDTO,
|
||||
TResendOrgInviteDTO,
|
||||
TServerConfig,
|
||||
TUpdateServerConfigDTO,
|
||||
TUsageReportResponse
|
||||
@@ -195,6 +198,50 @@ export const useInvalidateCache = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useServerAdminCreateOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (opt: TCreateOrganizationDTO) => {
|
||||
const { data } = await apiRequest.post<{ organization: Organization }>(
|
||||
"/api/v1/admin/organization-management/organizations",
|
||||
opt
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useServerAdminResendOrgInvite = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, membershipId }: TResendOrgInviteDTO) => {
|
||||
await apiRequest.post(
|
||||
`/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}/resend-invite`
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useServerAdminAccessOrg = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orgId: string) => {
|
||||
const { data } = await apiRequest.post(
|
||||
`/api/v1/admin/organization-management/organizations/${orgId}/access`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations });
|
||||
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateUsageReport = () => {
|
||||
return useMutation<TUsageReportResponse, object, void>({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
UndefinedInitialDataInfiniteOptions,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
UseQueryOptions
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { Identity } from "@app/hooks/api/identities/types";
|
||||
@@ -25,8 +32,8 @@ export const adminStandaloneKeys = {
|
||||
export const adminQueryKeys = {
|
||||
serverConfig: () => ["server-config"] as const,
|
||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||
getOrganizations: (filters: AdminGetOrganizationsFilters) =>
|
||||
[adminStandaloneKeys.getOrganizations, { filters }] as const,
|
||||
getOrganizations: (filters?: AdminGetOrganizationsFilters) =>
|
||||
[adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const,
|
||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||
@@ -83,7 +90,18 @@ export const useGetServerConfig = ({
|
||||
enabled: options?.enabled ?? true
|
||||
});
|
||||
|
||||
export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
||||
export const useAdminGetUsers = (
|
||||
filters: AdminGetUsersFilters,
|
||||
options?: Partial<
|
||||
UndefinedInitialDataInfiniteOptions<
|
||||
User[],
|
||||
DefaultError,
|
||||
InfiniteData<User[]>,
|
||||
ReturnType<typeof adminQueryKeys.getUsers>,
|
||||
number
|
||||
>
|
||||
>
|
||||
) => {
|
||||
return useInfiniteQuery({
|
||||
initialPageParam: 0,
|
||||
queryKey: adminQueryKeys.getUsers(filters),
|
||||
@@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
||||
return data.users;
|
||||
},
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.length !== 0 ? pages.length * filters.limit : undefined
|
||||
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
|
||||
|
||||
import { Organization } from "../types";
|
||||
|
||||
export enum LoginMethod {
|
||||
@@ -20,6 +22,7 @@ export type OrganizationWithProjects = Organization & {
|
||||
lastName: string | null;
|
||||
};
|
||||
membershipId: string;
|
||||
status: OrgMembershipStatus;
|
||||
role: string;
|
||||
roleId: string | null;
|
||||
}[];
|
||||
@@ -149,3 +152,13 @@ export type TUsageReportResponse = {
|
||||
csvContent: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type TCreateOrganizationDTO = {
|
||||
name: string;
|
||||
inviteAdminEmails: string[];
|
||||
};
|
||||
|
||||
export type TResendOrgInviteDTO = {
|
||||
organizationId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
useCreateGithubSyncOrgConfig,
|
||||
useDeleteGithubSyncOrgConfig,
|
||||
useSyncAllGithubTeams,
|
||||
useUpdateGithubSyncOrgConfig
|
||||
} from "./mutations";
|
||||
export { githubOrgSyncConfigQueryKeys } from "./queries";
|
||||
|
||||
@@ -40,3 +40,19 @@ export const useDeleteGithubSyncOrgConfig = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useSyncAllGithubTeams = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<{
|
||||
totalUsers: number;
|
||||
errors: string[];
|
||||
createdTeams: string[];
|
||||
updatedTeams: string[];
|
||||
removedMemberships: number;
|
||||
syncDuration: number;
|
||||
}> => {
|
||||
const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams");
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalMigrationProviders } from "./types";
|
||||
|
||||
const externalMigrationQueryKeys = {
|
||||
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
||||
"custom-migration-available",
|
||||
provider
|
||||
]
|
||||
};
|
||||
|
||||
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.customMigrationAvailable(provider),
|
||||
queryFn: () =>
|
||||
apiRequest.get<{ enabled: boolean }>(
|
||||
`/api/v3/external-migration/custom-migration-enabled/${provider}`
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ExternalMigrationProviders {
|
||||
Vault = "vault",
|
||||
EnvKey = "env-key"
|
||||
}
|
||||
@@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy {
|
||||
Name = "name",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
export enum OrgMembershipStatus {
|
||||
Invited = "invited",
|
||||
Accepted = "accepted"
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ export type User = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
username: string;
|
||||
email?: string;
|
||||
email?: string | null;
|
||||
superAdmin: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
authProvider?: AuthMethod;
|
||||
authMethods: AuthMethod[];
|
||||
isMfaEnabled: boolean;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faMobile } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
|
||||
import { Banner } from "@app/components/page-frames/Banner";
|
||||
import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useServerConfig, useSubscription } from "@app/context";
|
||||
import { useFetchServerStatus } from "@app/hooks/api";
|
||||
import { AuditLogBanner } from "@app/layouts/OrganizationLayout/components/AuditLogBanner";
|
||||
import { Navbar } from "@app/layouts/OrganizationLayout/components/NavBar";
|
||||
import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBanner";
|
||||
import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner";
|
||||
|
||||
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
|
||||
import { AdminSidebar } from "./Sidebar";
|
||||
@@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar";
|
||||
export const AdminLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useServerConfig();
|
||||
|
||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||
|
||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
||||
const { data: serverDetails, isLoading } = useFetchServerStatus();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner />
|
||||
<div className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden md:flex`}>
|
||||
<div
|
||||
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
|
||||
>
|
||||
<Navbar />
|
||||
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
|
||||
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
|
||||
{!isLoading && subscription.auditLogs && <AuditLogBanner />}
|
||||
{!window.isSecureContext && <InsecureConnectionBanner />}
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 pt-8 dark:[color-scheme:dark]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faBuilding,
|
||||
faChevronLeft,
|
||||
faCog,
|
||||
faDatabase,
|
||||
faKey,
|
||||
faLock,
|
||||
faPlug,
|
||||
faUserTie
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
|
||||
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||
import { Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||
|
||||
const generalTabs = [
|
||||
{
|
||||
label: "General",
|
||||
icon: "settings-cog",
|
||||
icon: faCog,
|
||||
link: "/admin/"
|
||||
},
|
||||
{
|
||||
label: "Encryption",
|
||||
icon: "lock-closed",
|
||||
icon: faLock,
|
||||
link: "/admin/encryption"
|
||||
},
|
||||
{
|
||||
label: "Authentication",
|
||||
icon: "check",
|
||||
icon: faCheckCircle,
|
||||
link: "/admin/authentication"
|
||||
},
|
||||
{
|
||||
label: "Integrations",
|
||||
icon: "sliding-carousel",
|
||||
icon: faPlug,
|
||||
link: "/admin/integrations"
|
||||
},
|
||||
{
|
||||
label: "Caching",
|
||||
icon: "note",
|
||||
icon: faDatabase,
|
||||
link: "/admin/caching"
|
||||
},
|
||||
{
|
||||
label: "Environment Variables",
|
||||
icon: "unlock",
|
||||
icon: faKey,
|
||||
link: "/admin/environment"
|
||||
}
|
||||
];
|
||||
|
||||
const resourceTabs = [
|
||||
const othersTabs = [
|
||||
{
|
||||
label: "Organizations",
|
||||
icon: "groups",
|
||||
link: "/admin/resources/organizations"
|
||||
label: "Access Controls",
|
||||
icon: faUserTie,
|
||||
link: "/admin/access-management"
|
||||
},
|
||||
{
|
||||
label: "User Identities",
|
||||
icon: "user",
|
||||
link: "/admin/resources/user-identities"
|
||||
},
|
||||
{
|
||||
label: "Machine Identities",
|
||||
icon: "wrench",
|
||||
link: "/admin/resources/machine-identities"
|
||||
label: "Resource Overview",
|
||||
icon: faBuilding,
|
||||
link: "/admin/resources/overview"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -61,6 +66,44 @@ export const AdminSidebar = () => {
|
||||
return (
|
||||
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
|
||||
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
|
||||
<div className="flex-1">
|
||||
<Menu>
|
||||
<MenuGroup title="Configuration">
|
||||
{generalTabs.map((tab) => {
|
||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||
return (
|
||||
<Link key={tab.link} to={tab.link}>
|
||||
<MenuItem isSelected={Boolean(isActive)}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={tab.icon} />
|
||||
</div>
|
||||
{tab.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<MenuGroup title="Others">
|
||||
{othersTabs.map((tab) => {
|
||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||
return (
|
||||
<Link key={tab.link} to={tab.link}>
|
||||
<MenuItem isSelected={Boolean(isActive)}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={tab.icon} />
|
||||
</div>
|
||||
{tab.label}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
</div>
|
||||
<Menu>
|
||||
<Link to="/organization/projects">
|
||||
<MenuItem
|
||||
@@ -73,50 +116,10 @@ export const AdminSidebar = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Back to organization
|
||||
Back to Organization
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</Menu>
|
||||
<div className="flex-grow">
|
||||
<Menu>
|
||||
<MenuGroup title="General">
|
||||
{generalTabs.map((tab) => {
|
||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||
return (
|
||||
<Link key={tab.link} to={tab.link}>
|
||||
<MenuItem
|
||||
className="relative flex items-center gap-2 overflow-hidden rounded-none"
|
||||
leftIcon={
|
||||
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
|
||||
}
|
||||
isSelected={Boolean(isActive)}
|
||||
>
|
||||
{tab.label}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<MenuGroup title="Resources">
|
||||
{resourceTabs.map((tab) => {
|
||||
const isActive = matchRoute({ to: tab.link, fuzzy: false });
|
||||
return (
|
||||
<Link key={tab.link} to={tab.link}>
|
||||
<MenuItem
|
||||
className="relative flex items-center gap-2 overflow-hidden rounded-none"
|
||||
leftIcon={
|
||||
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
|
||||
}
|
||||
isSelected={Boolean(isActive)}
|
||||
>
|
||||
{tab.label}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
faEnvelope,
|
||||
faInfo,
|
||||
faInfoCircle,
|
||||
faServer,
|
||||
faSignOut,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
|
||||
import { Mfa } from "@app/components/auth/Mfa";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -117,7 +118,7 @@ export const Navbar = () => {
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const location = useLocation();
|
||||
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
|
||||
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
|
||||
|
||||
@@ -179,6 +180,8 @@ export const Navbar = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isServerAdminPanel = location.pathname.startsWith("/admin");
|
||||
|
||||
return (
|
||||
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
|
||||
<div>
|
||||
@@ -187,96 +190,117 @@ export const Navbar = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu modal={false}>
|
||||
<Link to="/organization/projects">
|
||||
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{currentOrg?.name}</div>
|
||||
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
|
||||
{getPlan(subscription)}
|
||||
</div>
|
||||
</div>
|
||||
</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="start"
|
||||
side="bottom"
|
||||
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
|
||||
style={{ minWidth: "220px" }}
|
||||
{isServerAdminPanel ? (
|
||||
<>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"
|
||||
>
|
||||
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">organizations</div>
|
||||
{orgs?.map((org) => {
|
||||
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);
|
||||
}}
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">Server Console</div>
|
||||
</Link>
|
||||
<p className="pl-3 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
{breadcrumbs ? (
|
||||
// scott: remove /admin as we show server console above
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu modal={false}>
|
||||
<Link to="/organization/projects">
|
||||
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{currentOrg?.name}</div>
|
||||
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
|
||||
{getPlan(subscription)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
<IconButton
|
||||
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" />
|
||||
)
|
||||
}
|
||||
ariaLabel="switch-org"
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
||||
{org.name}
|
||||
</div>
|
||||
</Button>
|
||||
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
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 capitalize text-mineshaft-400">organizations</div>
|
||||
{orgs?.map((org) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
|
||||
Log Out
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
|
||||
Log Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
|
||||
{breadcrumbs ? (
|
||||
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="flex-grow" />
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
|
||||
+2
-2
@@ -27,11 +27,11 @@ export const OrgAlertBanner = ({ text, link }: Props) => {
|
||||
target="_blank"
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="cursor-pointer pl-1 text-yellow-500 underline underline-offset-2 duration-100 group-hover:text-mineshaft-100 group-hover:decoration-mineshaft-100">
|
||||
<span className="cursor-pointer pl-1 underline underline-offset-2 duration-100 group-hover:text-mineshaft-100 group-hover:decoration-mineshaft-100">
|
||||
here
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
className="ml-0.5 mt-0.5 text-yellow group-hover:text-mineshaft-100"
|
||||
className="ml-1 mt-[0.12rem] group-hover:text-mineshaft-100"
|
||||
icon={faArrowUpRightFromSquare}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
+6
-6
@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { OrganizationsTable } from "./components";
|
||||
import { ServerAdminsTable } from "./components";
|
||||
|
||||
export const OrganizationResourcesPage = () => {
|
||||
export const AccessManagementPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
<title>{t("common.head-title", { title: "Access Control" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Organizations"
|
||||
description="Manage all organizations within your Infisical instance."
|
||||
title="Access Control"
|
||||
description="Manage server admins within your Infisical instance."
|
||||
/>
|
||||
<OrganizationsTable />
|
||||
<ServerAdminsTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const getUserLabel = (user: Pick<User, "email" | "firstName" | "lastName" | "username" | "id">) => {
|
||||
const { firstName, lastName, username, email } = user;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddServerAdminSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddServerAdminSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const grantAdmin = useAdminGrantServerAdminAccess();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(AddServerAdminSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ user }: FormData) => {
|
||||
try {
|
||||
await grantAdmin.mutateAsync(user.id);
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully granted server admin status"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to grant server admin status",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="User">
|
||||
<FilterableSelect
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users..."
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="user"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Grant Server Admin"
|
||||
subTitle="Grant server admin status to a user"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,476 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faShieldHalved,
|
||||
faTrash,
|
||||
faUsers,
|
||||
faUserXmark,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useUser } from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminBulkDeleteUsers,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetUsers,
|
||||
useRemoveUserServerAdminAccess
|
||||
} from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal";
|
||||
|
||||
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
|
||||
|
||||
const ServerAdminsPanelTable = ({
|
||||
handlePopUpOpen,
|
||||
users: usersPages,
|
||||
isPending,
|
||||
searchUserFilter,
|
||||
setSearchUserFilter,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
selectedUsers,
|
||||
setSelectedUsers
|
||||
}: {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"]
|
||||
>,
|
||||
data?: {
|
||||
username: string;
|
||||
id: string;
|
||||
message?: string;
|
||||
}
|
||||
) => void;
|
||||
isPending: boolean;
|
||||
users: InfiniteData<User[], unknown> | undefined;
|
||||
searchUserFilter: string;
|
||||
setSearchUserFilter: (filter: string) => void;
|
||||
selectedUsers: User[];
|
||||
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
}) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const users = usersPages?.pages.flat();
|
||||
|
||||
const isEmpty = !isPending && !users?.length;
|
||||
|
||||
const selectedUserIds = selectedUsers.map((user) => user.id);
|
||||
|
||||
const isPageSelected = users?.length
|
||||
? users.every((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const isPageIndeterminate = isPageSelected
|
||||
? false
|
||||
: users?.length
|
||||
? users?.some((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search admins..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addServerAdmin")}
|
||||
>
|
||||
Add Admin
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5">
|
||||
<Checkbox
|
||||
id="member-page-select"
|
||||
isChecked={isPageSelected || isPageIndeterminate}
|
||||
isIndeterminate={isPageIndeterminate}
|
||||
onCheckedChange={() => {
|
||||
if (isPageSelected) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.filter((u) => !users?.find((user) => user.id === u.id))
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((prev) => [
|
||||
...prev,
|
||||
...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? [])
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Th>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-1/2">Username</Th>
|
||||
<Th className="w-2/12" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="users" />}
|
||||
{!isPending &&
|
||||
users?.map((user) => {
|
||||
const { username, email, firstName, lastName, id } = user;
|
||||
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||
|
||||
const isSelected = selectedUserIds.includes(id);
|
||||
return (
|
||||
<Tr key={`user-${id}`} className="w-full">
|
||||
<Td>
|
||||
<Checkbox
|
||||
id={`select-user-${id}`}
|
||||
isChecked={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUsers((prev) =>
|
||||
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
</p>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeUser", { username, id });
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
>
|
||||
Remove User
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
icon={
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faShieldHalved} />
|
||||
<FontAwesomeIcon
|
||||
className="absolute -bottom-[0.01rem] -right-1"
|
||||
size="2xs"
|
||||
icon={faXmark}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!subscription?.instanceUserManagement) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
username,
|
||||
id,
|
||||
message: removeServerAdminUpgradePlanMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("removeServerAdmin", { username, id });
|
||||
}}
|
||||
>
|
||||
Remove Server Admin
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of List"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServerAdminsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"removeUser",
|
||||
"upgradePlan",
|
||||
"addServerAdmin",
|
||||
"removeServerAdmin",
|
||||
"removeUsers"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
user: { id: userId }
|
||||
} = useUser();
|
||||
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers();
|
||||
const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage
|
||||
} = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: true
|
||||
});
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
const { id } = popUp?.removeUser?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await deleteUser(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error deleting user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeUser");
|
||||
};
|
||||
|
||||
const handleRemoveServerAdminAccess = async () => {
|
||||
const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await removeAdminAccess(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed server admin access from user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error removing server admin access from user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeServerAdmin");
|
||||
};
|
||||
|
||||
const handleRemoveUsers = async () => {
|
||||
try {
|
||||
await deleteUsers(selectedUsers.map((user) => user.id));
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed users",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
handlePopUpClose("removeUsers");
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to remove users",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={twMerge(
|
||||
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||
selectedUsers.length > 0 && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
|
||||
onClick={() => setSelectedUsers([])}
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
if (!selectedUsers?.length) return;
|
||||
|
||||
handlePopUpOpen("removeUsers");
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<ServerAdminsPanelTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
setSelectedUsers={setSelectedUsers}
|
||||
searchUserFilter={searchUserFilter}
|
||||
setSearchUserFilter={setSearchUserFilter}
|
||||
isPending={isPending}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUser.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Are you sure you want to delete User with username ${
|
||||
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
|
||||
onDeleteApproved={handleRemoveUser}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeServerAdmin.isOpen}
|
||||
title={`Are you sure you want to remove Server Admin permissions from ${
|
||||
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
subTitle=""
|
||||
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleRemoveServerAdminAccess}
|
||||
buttonText="Remove Access"
|
||||
/>
|
||||
<AddServerAdminModal
|
||||
isOpen={popUp.addServerAdmin.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addServerAdmin", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUsers.isOpen}
|
||||
title="Are you sure you want to delete the following users?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleRemoveUsers()}
|
||||
buttonText="Remove"
|
||||
>
|
||||
<div className="mt-4 text-sm text-mineshaft-400">
|
||||
The following users will be deleted:
|
||||
</div>
|
||||
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||
<ul className="list-disc">
|
||||
{selectedUsers?.map((user) => {
|
||||
const email = user.email ?? user.username;
|
||||
return (
|
||||
<li key={user.id}>
|
||||
<div className="flex items-center">
|
||||
<p>
|
||||
{user.firstName || user.lastName ? (
|
||||
<>
|
||||
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
|
||||
<span className="break-all">{email}</span>)
|
||||
</>
|
||||
) : (
|
||||
<span className="break-all">{email}</span>
|
||||
)}{" "}
|
||||
</p>
|
||||
{userId === user.id && (
|
||||
<Tooltip content="Are you sure you want to remove yourself from this instance?">
|
||||
<div className="inline-block">
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>Deleting Yourself</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</DeleteActionModal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ServerAdminsTable";
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
|
||||
import { AccessManagementPage } from "./AccessManagementPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities"
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/access-management"
|
||||
)({
|
||||
component: UserIdentitiesResourcesPage,
|
||||
component: AccessManagementPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "User Identities",
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/user-identities"
|
||||
to: "/admin/access-management"
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -103,7 +103,7 @@ export const GeneralPageForm = () => {
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-8 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
className="space-y-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className="flex flex-col justify-start">
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable } from "./components";
|
||||
|
||||
export const MachineIdentitiesResourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Machine Identities"
|
||||
description="Manage all machine identities within your Infisical instance."
|
||||
/>
|
||||
<MachineIdentitiesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
|
||||
)({
|
||||
component: MachineIdentitiesResourcesPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Admin",
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Machine Identities",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/machine-identities"
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faBuilding,
|
||||
faCircleQuestion,
|
||||
faEllipsis,
|
||||
faMagnifyingGlass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminDeleteOrganization,
|
||||
useAdminDeleteOrganizationMembership,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetOrganizations
|
||||
} from "@app/hooks/api";
|
||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const ViewMembersModalContent = ({
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
popUp: UsePopUpState<["viewMembers"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||
data?: {
|
||||
username?: string;
|
||||
membershipId?: string;
|
||||
userId?: string;
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
}) => {
|
||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{organization?.members?.map((member) => (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-mineshaft-100">
|
||||
<div>
|
||||
{member.user.firstName ? (
|
||||
<div>
|
||||
{member.user.firstName} {member.user.lastName}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-mineshaft-400">Not set</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-80">
|
||||
<div>{member.user.username || member.user.email}</div>
|
||||
<Badge variant="primary">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
||||
{Boolean(member.roleId) && (
|
||||
<Tooltip content="This member has a custom role assigned.">
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<FontAwesomeIcon
|
||||
icon={faEllipsis}
|
||||
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteOrganizationMembership", {
|
||||
membershipId: member.membershipId,
|
||||
orgId: organization.id,
|
||||
username: member.user.username,
|
||||
orgName: organization.name
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove From Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewMembersModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||
) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
title="Organization Members"
|
||||
subTitle="View the members of the organization."
|
||||
>
|
||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationsPanelTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: {
|
||||
popUp: UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>,
|
||||
data?: {
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
message?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||
isOpen?: boolean
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useAdminGetOrganizations({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchOrganizationsFilter}
|
||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organizations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-5/12">Members</Th>
|
||||
<Th className="w-5/12">Projects</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((orgs) =>
|
||||
orgs.map((org) => {
|
||||
return (
|
||||
<Tr key={`org-${org.id}`} className="w-full">
|
||||
<Td className="w-5/12">
|
||||
{org.name ? (
|
||||
org.name
|
||||
) : (
|
||||
<span className="text-mineshaft-400">Not set</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.members.length} {org.members.length === 1 ? "member" : "members"}
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="ml-2"
|
||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||
>
|
||||
View Members
|
||||
</Button>
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteOrganization", {
|
||||
orgId: org.id,
|
||||
orgName: org.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ViewMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
isOpen={popUp.viewMembers.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteOrganization",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"viewMembers"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||
|
||||
await deleteOrganization(orgId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrganization");
|
||||
};
|
||||
|
||||
const handleDeleteOrganizationMembership = async () => {
|
||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
if (!orgId || !membershipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed user from organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteOrganizationMembership");
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteUser");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<OrganizationsPanelTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganization.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete organization ${
|
||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganization}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||
} from organization ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteUser.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete user ${
|
||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||
onDeleteApproved={handleDeleteUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { OrganizationsTable } from "./OrganizationsTable";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components";
|
||||
|
||||
export const ResourceOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Resource Overview"
|
||||
description="Manage resources within your Infisical instance."
|
||||
/>
|
||||
<Tabs defaultValue="tab-organizations">
|
||||
<TabList>
|
||||
<Tab value="tab-organizations">Organizations</Tab>
|
||||
<Tab value="tab-users">Users</Tab>
|
||||
<Tab value="tab-identities">Identities</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="tab-organizations">
|
||||
<OrganizationsTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-users">
|
||||
<UserIdentitiesTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-identities">
|
||||
<MachineIdentitiesTable />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Invitee = Pick<User, "email" | "firstName" | "lastName" | "username" | "id">;
|
||||
type NewOption = { label: string; value: string };
|
||||
|
||||
const getUserLabel = (user: Invitee | NewOption) => {
|
||||
if (Object.prototype.hasOwnProperty.call(user, "value")) {
|
||||
return (user as NewOption).label;
|
||||
}
|
||||
|
||||
const { firstName, lastName, username, email } = user as Invitee;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddOrgSchema = z.object({
|
||||
name: GenericResourceNameSchema.nonempty("Organization name required"),
|
||||
invitees: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string().nullish()
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one admin is required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddOrgSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const createOrg = useServerAdminCreateOrganization();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
invitees: []
|
||||
},
|
||||
resolver: zodResolver(AddOrgSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ name, invitees }: FormData) => {
|
||||
try {
|
||||
await createOrg.mutateAsync({
|
||||
name,
|
||||
inviteAdminEmails: invitees
|
||||
.filter((user) => Boolean(user.email))
|
||||
.map((user) => user.email) as string[]
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created organization"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to create organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { append } = useFieldArray<FormData>({ control, name: "invitees" });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Name">
|
||||
<Input autoFocus value={value} onChange={onChange} placeholder="My Organization" />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Assign Organization Admins"
|
||||
>
|
||||
<CreatableSelect
|
||||
/* eslint-disable-next-line react/no-unstable-nested-components */
|
||||
noOptionsMessage={() => (
|
||||
<p>Invite new users to this organization by typing out their email address.</p>
|
||||
)}
|
||||
onCreateOption={(inputValue) =>
|
||||
append({ id: `${inputValue}_${Math.random()}`, email: inputValue })
|
||||
}
|
||||
formatCreateLabel={(inputValue) => `Invite "${inputValue}"`}
|
||||
isValidNewOption={(input) =>
|
||||
Boolean(input) &&
|
||||
z.string().email().safeParse(input).success &&
|
||||
!users
|
||||
?.flatMap((user) => {
|
||||
const emails: string[] = [];
|
||||
|
||||
if (user.email) {
|
||||
emails.push(user.email);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
emails.push(user.username);
|
||||
}
|
||||
|
||||
return emails;
|
||||
})
|
||||
.includes(input)
|
||||
}
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users or invite new ones..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="invitees"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Add Organization
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Organization">
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+8
-2
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faServer,
|
||||
faShieldHalved,
|
||||
faWrench,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Machine Identities</p>
|
||||
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeServerAdmin.isOpen}
|
||||
@@ -0,0 +1,752 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faBuilding,
|
||||
faCircleQuestion,
|
||||
faEllipsisV,
|
||||
faEnvelope,
|
||||
faEye,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faTrash,
|
||||
faUserCheck,
|
||||
faUserMinus,
|
||||
faUserPlus,
|
||||
faUsers,
|
||||
faUserXmark,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useAdminDeleteOrganization,
|
||||
useAdminDeleteOrganizationMembership,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetOrganizations,
|
||||
useServerAdminAccessOrg,
|
||||
useServerAdminResendOrgInvite
|
||||
} from "@app/hooks/api";
|
||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { AddOrganizationModal } from "@app/pages/admin/ResourceOverviewPage/components/AddOrganizationModal";
|
||||
|
||||
enum MembersOrderBy {
|
||||
Name = "firstName",
|
||||
Email = "email"
|
||||
}
|
||||
|
||||
const ORG_MEMBERS_TABLE_LIMIT = 15;
|
||||
|
||||
const ViewMembersModalContent = ({
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
popUp: UsePopUpState<["viewMembers"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||
data?: {
|
||||
username?: string;
|
||||
membershipId?: string;
|
||||
userId?: string;
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
}) => {
|
||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
|
||||
|
||||
const members = organization?.members ?? [];
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
|
||||
initPerPage: ORG_MEMBERS_TABLE_LIMIT
|
||||
});
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() =>
|
||||
members
|
||||
?.filter(
|
||||
({ user: u }) =>
|
||||
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.email?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
let valueOne: string | null;
|
||||
let valueTwo: string | null;
|
||||
|
||||
switch (orderBy) {
|
||||
case MembersOrderBy.Email:
|
||||
valueOne = memberOne.user.email || memberOne.user.username;
|
||||
valueTwo = memberTwo.user.email || memberTwo.user.username;
|
||||
break;
|
||||
case MembersOrderBy.Name:
|
||||
default:
|
||||
valueOne = memberOne.user.firstName ?? memberOne.user.lastName;
|
||||
valueTwo = memberTwo.user.firstName ?? memberTwo.user.lastName;
|
||||
}
|
||||
|
||||
if (!valueOne) return 1;
|
||||
if (!valueTwo) return -1;
|
||||
|
||||
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||
}),
|
||||
[members, search, orderBy, orderDirection]
|
||||
);
|
||||
|
||||
const handleSort = (column: MembersOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredMembers.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const resendOrgInvite = useServerAdminResendOrgInvite();
|
||||
|
||||
const onResendInvite = async (membershipId: string) => {
|
||||
setResendInviteId(membershipId);
|
||||
try {
|
||||
await resendOrgInvite.mutateAsync({
|
||||
membershipId,
|
||||
organizationId: organization.id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully resent org invitation",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to resend org invitation",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setResendInviteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer
|
||||
className={twMerge(
|
||||
"mt-4 flex flex-1 flex-col border border-mineshaft-500 bg-mineshaft-700",
|
||||
Boolean(filteredMembers.length) && "rounded-b-none"
|
||||
)}
|
||||
>
|
||||
<Table className="overflow-y-auto bg-mineshaft-700">
|
||||
<THead className="sticky top-0 z-50">
|
||||
<Tr>
|
||||
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Email
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Email)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Role
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5" />
|
||||
</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredMembers.slice(offset, perPage * page).map((member) => {
|
||||
const { username, email, firstName, lastName, id } = member.user;
|
||||
const { role, status } = member;
|
||||
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||
|
||||
return (
|
||||
<Tr key={`user-${id}`} className="w-full">
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center">
|
||||
<p className="truncate">
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center">
|
||||
<p className="truncate">{username || email}</p>
|
||||
{role === OrgMembershipRole.Admin &&
|
||||
status !== OrgMembershipStatus.Accepted && (
|
||||
<Button
|
||||
isDisabled={resendOrgInvite.isPending}
|
||||
className="ml-2 h-7 border-mineshaft-600 bg-mineshaft-800/50 font-normal"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isLoading={
|
||||
resendOrgInvite.isPending && resendInviteId === member.membershipId
|
||||
}
|
||||
leftIcon={<FontAwesomeIcon icon={faEnvelope} />}
|
||||
onClick={(e) => {
|
||||
onResendInvite(member.membershipId);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Resend Invite
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="max-w-0">
|
||||
<Badge className="flex w-fit max-w-full items-center gap-x-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
|
||||
<p className="truncate capitalize">{member.role.replace("-", " ")}</p>
|
||||
{Boolean(member.roleId) && (
|
||||
<Tooltip content="This member has a custom role assigned.">
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteOrganizationMembership", {
|
||||
membershipId: member.membershipId,
|
||||
orgId: organization.id,
|
||||
username: member.user.username,
|
||||
orgName: organization.name
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove From Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteUser", { userId: member.user.id })
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!filteredMembers.length && (
|
||||
<EmptyState
|
||||
className="my-auto bg-mineshaft-700"
|
||||
title={
|
||||
members.length
|
||||
? "No organization members match search..."
|
||||
: "No organization members found"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
{Boolean(filteredMembers.length) && (
|
||||
<Pagination
|
||||
className="rounded-b-md border border-t-0 bg-mineshaft-700"
|
||||
count={filteredMembers.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
perPageList={[ORG_MEMBERS_TABLE_LIMIT]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewMembersModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||
) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
title="Organization Members"
|
||||
subTitle="View the members of the organization."
|
||||
className="h-full max-w-4xl"
|
||||
bodyClassName="flex flex-col h-full"
|
||||
>
|
||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationsPanelTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: {
|
||||
popUp: UsePopUpState<
|
||||
[
|
||||
"deleteOrganization",
|
||||
"viewMembers",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"createOrganization"
|
||||
]
|
||||
>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
[
|
||||
"deleteOrganization",
|
||||
"viewMembers",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"createOrganization"
|
||||
]
|
||||
>,
|
||||
data?: {
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
message?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||
isOpen?: boolean
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useAdminGetOrganizations({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
|
||||
|
||||
const handleAccessOrg = async (orgId: string) => {
|
||||
try {
|
||||
await accessOrganization(orgId);
|
||||
|
||||
navigate({
|
||||
to: "/login/select-organization",
|
||||
search: {
|
||||
org_id: orgId
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully joined organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to join organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={searchOrganizationsFilter}
|
||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organizations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/2">Name</Th>
|
||||
<Th className="w-1/3">Members</Th>
|
||||
<Th className="w-1/3">Projects</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((orgs) =>
|
||||
orgs.map((org) => {
|
||||
const isMember = org.members.find((member) => member.user.id === user.id);
|
||||
|
||||
return (
|
||||
<Tr key={`org-${org.id}`} className="w-full">
|
||||
<Td className="w-1/2 max-w-0">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{org.name ? (
|
||||
<p className="truncate">{org.name}</p>
|
||||
) : (
|
||||
<span className="text-mineshaft-400">Not Set</span>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="w-1/3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||
className="flex items-center hover:underline"
|
||||
>
|
||||
<Tooltip className="text-center" content="View Members">
|
||||
<FontAwesomeIcon
|
||||
icon={faEye}
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
|
||||
{!org.members.some(
|
||||
(member) =>
|
||||
member.role === OrgMembershipRole.Admin &&
|
||||
member.status === OrgMembershipStatus.Accepted
|
||||
) && (
|
||||
<Tooltip content="No admins have accepted their invitations.">
|
||||
<div className="ml-1.5">
|
||||
<FontAwesomeIcon className="text-yellow" icon={faWarning} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</button>
|
||||
</Td>
|
||||
<Td className="w-1/3">
|
||||
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end gap-x-1">
|
||||
{isMember && (
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content="You are a member of this organization"
|
||||
>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
className="text-mineshaft-400"
|
||||
icon={faUserCheck}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
{!isMember && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAccessOrg(org.id);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faUserPlus} />}
|
||||
>
|
||||
Join Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteOrganization", {
|
||||
orgId: org.id,
|
||||
orgName: org.name
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ViewMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
isOpen={popUp.viewMembers.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteOrganization",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"viewMembers",
|
||||
"createOrganization"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||
|
||||
await deleteOrganization(orgId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrganization");
|
||||
};
|
||||
|
||||
const handleDeleteOrganizationMembership = async () => {
|
||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
if (!orgId || !membershipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed user from organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteOrganizationMembership");
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteUser");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Organizations</p>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Manage, join and view organizations across your instance.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => handlePopUpOpen("createOrganization")}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Organization
|
||||
</Button>
|
||||
</div>
|
||||
<OrganizationsPanelTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganization.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete organization ${
|
||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganization}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||
} from organization ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteUser.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete user ${
|
||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||
onDeleteApproved={handleDeleteUser}
|
||||
/>
|
||||
<AddOrganizationModal
|
||||
isOpen={popUp.createOrganization.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createOrganization", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+11
-5
@@ -218,7 +218,7 @@ const UserPanelTable = ({
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{email}</p>
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">User Identities</p>
|
||||
<p className="text-sm text-bunker-300">Manage user identities across your instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserPanelTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
@@ -515,14 +521,14 @@ export const UserIdentitiesTable = () => {
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUsers.isOpen}
|
||||
title="Are you sure you want to remove the following users?"
|
||||
title="Are you sure you want to delete the following users?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleRemoveUsers()}
|
||||
buttonText="Remove"
|
||||
buttonText="Delete"
|
||||
>
|
||||
<div className="mt-4 text-sm text-mineshaft-400">
|
||||
The following members will be removed:
|
||||
The following users will be deleted:
|
||||
</div>
|
||||
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||
<ul className="list-disc">
|
||||
@@ -549,7 +555,7 @@ export const UserIdentitiesTable = () => {
|
||||
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>Removing Yourself</span>
|
||||
<span>Deleting Yourself</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./MachineIdentitiesTable";
|
||||
export * from "./OrganizationsTable";
|
||||
export * from "./UserIdentitiesTable";
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
|
||||
import { ResourceOverviewPage } from "./ResourceOverviewPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations"
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview"
|
||||
)({
|
||||
component: OrganizationResourcesPage,
|
||||
component: ResourceOverviewPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Organizations",
|
||||
label: "Resource Overview",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/organizations"
|
||||
to: "/admin/resources/overview"
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { UserIdentitiesTable } from "./components";
|
||||
|
||||
export const UserIdentitiesResourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="User Identities"
|
||||
description="Manage all user identities within your Infisical instance."
|
||||
/>
|
||||
<UserIdentitiesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { UserIdentitiesTable } from "./UserIdentitiesTable";
|
||||
@@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery";
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
callback_port: z.coerce.number().optional().catch(undefined),
|
||||
force: z.boolean().optional()
|
||||
force: z.boolean().optional(),
|
||||
org_id: z.string().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export const AuthConsentWrapper = () => {
|
||||
@@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
return;
|
||||
}
|
||||
|
||||
if (search.org_id) {
|
||||
if (location.pathname.endsWith("select-organization")) return;
|
||||
|
||||
throw redirect({ to: "/login/select-organization", search: { org_id: search.org_id } });
|
||||
}
|
||||
|
||||
if (!data.organizationId) {
|
||||
if (
|
||||
location.pathname.endsWith("select-organization") ||
|
||||
|
||||
+63
-2
@@ -1,7 +1,9 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
@@ -9,9 +11,16 @@ import {
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { gatewaysQueryKeys } from "@app/hooks/api";
|
||||
import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
@@ -66,7 +75,8 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.HCVault,
|
||||
method: HCVaultConnectionMethod.AppRole
|
||||
method: HCVaultConnectionMethod.AppRole,
|
||||
gatewayId: null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,6 +89,9 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
|
||||
const selectedMethod = watch("method");
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -115,6 +128,54 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{subscription.gateway && (
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<Controller
|
||||
name="credentials.instanceUrl"
|
||||
control={control}
|
||||
|
||||
+45
-12
@@ -16,6 +16,8 @@ import {
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys } from "@app/hooks/api";
|
||||
import { useImportVault } from "@app/hooks/api/migration/mutations";
|
||||
import { useHasCustomMigrationAvailable } from "@app/hooks/api/migration";
|
||||
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
@@ -24,11 +26,13 @@ type Props = {
|
||||
|
||||
enum VaultMappingType {
|
||||
KeyVault = "key-vault",
|
||||
Namespace = "namespace"
|
||||
Namespace = "namespace",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
const MAPPING_TYPE_MENU_ITEMS = [
|
||||
{
|
||||
isCustom: false,
|
||||
value: VaultMappingType.KeyVault,
|
||||
label: "Key Vaults",
|
||||
tooltip: (
|
||||
@@ -48,6 +52,7 @@ const MAPPING_TYPE_MENU_ITEMS = [
|
||||
)
|
||||
},
|
||||
{
|
||||
isCustom: false,
|
||||
value: VaultMappingType.Namespace,
|
||||
label: "Namespaces",
|
||||
tooltip: (
|
||||
@@ -63,10 +68,25 @@ const MAPPING_TYPE_MENU_ITEMS = [
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
isCustom: true,
|
||||
value: VaultMappingType.Custom,
|
||||
label: "Custom Migration",
|
||||
tooltip: (
|
||||
<div>
|
||||
Custom migrations allow you to shape your Vault migration to your specific needs. Please
|
||||
contact our sales team to get started with custom migrations.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
const { data: isCustomMigrationAvailable } = useHasCustomMigrationAvailable(
|
||||
ExternalMigrationProviders.Vault
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
vaultUrl: z.string().min(1),
|
||||
gatewayId: z.string().optional(),
|
||||
@@ -230,31 +250,44 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="mt-2 grid h-full w-full grid-cols-2 gap-4">
|
||||
<div className="mt-2 grid grid-cols-2 gap-4">
|
||||
{MAPPING_TYPE_MENU_ITEMS.map((el) => (
|
||||
<div
|
||||
key={el.value}
|
||||
className={twMerge(
|
||||
"flex w-full cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all",
|
||||
field.value === el.value
|
||||
? "border-primary-700 border-opacity-70 bg-mineshaft-600 opacity-100"
|
||||
: "hover:border-primary-700 hover:bg-mineshaft-600"
|
||||
? "border-primary-700 border-opacity-70 bg-mineshaft-700 opacity-100"
|
||||
: "hover:border-primary-800/75 hover:bg-mineshaft-600",
|
||||
el.isCustom && "col-span-2",
|
||||
el.isCustom &&
|
||||
!isCustomMigrationAvailable?.data?.enabled &&
|
||||
"!cursor-not-allowed !border-mineshaft-600 !bg-mineshaft-600 !opacity-40"
|
||||
)}
|
||||
onClick={() => field.onChange(el.value)}
|
||||
onClick={() => {
|
||||
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
|
||||
|
||||
field.onChange(el.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
|
||||
|
||||
field.onChange(el.value);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
field.onChange(el.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-center text-sm">{el.label}</div>
|
||||
{el.tooltip && (
|
||||
<div className="text-center text-sm">
|
||||
<Tooltip content={el.tooltip} className="max-w-96">
|
||||
<FontAwesomeIcon className="opacity-60" icon={faQuestionCircle} />
|
||||
<FontAwesomeIcon
|
||||
size="sm"
|
||||
className="text-mineshaft-400"
|
||||
icon={faQuestionCircle}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,7 +305,7 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
|
||||
>
|
||||
Import data
|
||||
Import Data
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
|
||||
+6
-6
@@ -51,7 +51,7 @@ export const GithubOrgSyncConfigModal = ({
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
values: data ? { githubOrgName: data.githubOrgName } : undefined
|
||||
values: data ? { githubOrgName: data.githubOrgName, githubOrgAccessToken: "" } : undefined
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => {
|
||||
@@ -123,21 +123,21 @@ export const GithubOrgSyncConfigModal = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
<Controller
|
||||
control={control}
|
||||
name="githubOrgAccessToken"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="GitHub Org Scoped Access Token"
|
||||
label="GitHub Access Token"
|
||||
isError={Boolean(error)}
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
helperText="A GitHub access token is required only for private organizations. It will not be visible after saving."
|
||||
helperText="Required for manual sync operations. The token must have 'read:org' permissions."
|
||||
>
|
||||
<Input {...field} placeholder="example" />
|
||||
<Input {...field} type="password" placeholder="ghp_xxxxxxxxxxxx" />
|
||||
</FormControl>
|
||||
)}
|
||||
/> */}
|
||||
/>
|
||||
<div className="flex gap-8 pt-4">
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
{isUpdate ? "Update" : "Configure"}
|
||||
|
||||
+96
-1
@@ -1,10 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { githubOrgSyncConfigQueryKeys, useUpdateGithubSyncOrgConfig } from "@app/hooks/api";
|
||||
import {
|
||||
githubOrgSyncConfigQueryKeys,
|
||||
useSyncAllGithubTeams,
|
||||
useUpdateGithubSyncOrgConfig
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
|
||||
@@ -24,10 +29,73 @@ export const OrgGithubSyncSection = () => {
|
||||
});
|
||||
|
||||
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
|
||||
const syncAllTeamsMutation = useSyncAllGithubTeams();
|
||||
|
||||
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
|
||||
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
try {
|
||||
const result = await syncAllTeamsMutation.mutateAsync();
|
||||
let message = "Successfully synced teams";
|
||||
|
||||
const details = [];
|
||||
if (result.createdTeams.length > 0) {
|
||||
details.push(
|
||||
`${result.createdTeams.length} new team${result.createdTeams.length === 1 ? "" : "s"} created`
|
||||
);
|
||||
}
|
||||
if (result.updatedTeams.length > 0) {
|
||||
details.push(
|
||||
`${result.updatedTeams.length} team${result.updatedTeams.length === 1 ? "" : "s"} updated`
|
||||
);
|
||||
}
|
||||
if (result.removedMemberships > 0) {
|
||||
details.push(
|
||||
`${result.removedMemberships} membership${result.removedMemberships === 1 ? "" : "s"} removed`
|
||||
);
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
message += `. ${details.join(", ")}`;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: message,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
createNotification({
|
||||
text: `Sync completed with ${result.errors.length} warnings. Check the console for details.`,
|
||||
type: "warning"
|
||||
});
|
||||
console.warn("Sync errors:", result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as any)?.response?.data?.message || (error as Error)?.message || "Unknown error";
|
||||
|
||||
if (
|
||||
errorMessage.includes("token") &&
|
||||
(errorMessage.includes("required") ||
|
||||
errorMessage.includes("invalid") ||
|
||||
errorMessage.includes("expired") ||
|
||||
errorMessage.includes("set a token first"))
|
||||
) {
|
||||
createNotification({
|
||||
text: "Please set a GitHub access token in the configuration modal to continue with the sync",
|
||||
type: "error"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
text: `Failed to sync GitHub teams: ${errorMessage}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<p className="text-xl font-semibold text-gray-200">
|
||||
@@ -86,6 +154,33 @@ export const OrgGithubSyncSection = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{data && data.isActive && (
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">Sync Now</h2>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.GithubOrgSyncManual}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => handleBulkSync()}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed || syncAllTeamsMutation.isPending}
|
||||
isLoading={syncAllTeamsMutation.isPending}
|
||||
>
|
||||
Sync Now
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Manually sync GitHub teams for all organization members. This will update team
|
||||
memberships for users who have previously logged in with GitHub.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({
|
||||
}) => {
|
||||
const [selectedRequest, setSelectedRequest] = useState<
|
||||
| (TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
request: TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
+4574
-4767
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,8 @@ const adminRoute = route("/admin", [
|
||||
route("/environment", "admin/EnvironmentPage/route.tsx"),
|
||||
route("/integrations", "admin/IntegrationsPage/route.tsx"),
|
||||
route("/caching", "admin/CachingPage/route.tsx"),
|
||||
route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),
|
||||
route("/resources/user-identities", "admin/UserIdentitiesResourcesPage/route.tsx"),
|
||||
route("/resources/machine-identities", "admin/MachineIdentitiesResourcesPage/route.tsx")
|
||||
route("/resources/overview", "admin/ResourceOverviewPage/route.tsx"),
|
||||
route("/access-management", "admin/AccessManagementPage/route.tsx")
|
||||
])
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user