Merge branch 'main' into ENG-3506-LDAP

This commit is contained in:
x032205
2025-09-09 16:59:23 -04:00
222 changed files with 9373 additions and 2227 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,6 +1,7 @@
import {
faCheck,
faExclamationTriangle,
faHourglass,
faRotate,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
@@ -29,7 +30,11 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
text = "Synced";
icon = faCheck;
break;
case SecretSyncStatus.Pending: // no need to differentiate from user perspective
case SecretSyncStatus.Pending:
variant = "primary";
text = "Queued";
icon = faHourglass;
break;
case SecretSyncStatus.Running:
default:
variant = "primary";
@@ -42,11 +47,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}>
<FontAwesomeIcon
icon={icon}
className={
[SecretSyncStatus.Pending, SecretSyncStatus.Running].includes(status)
? "animate-spin"
: ""
}
className={[SecretSyncStatus.Running].includes(status) ? "animate-spin" : ""}
/>
<span>{text}</span>
</Badge>
@@ -11,9 +11,10 @@ type Props = {
text?: string | string[];
frequency?: number;
className?: string;
lottieClassName?: string;
};
export const ContentLoader = ({ text, frequency = 2000, className }: Props) => {
export const ContentLoader = ({ text, frequency = 2000, className, lottieClassName }: Props) => {
const [pos, setPos] = useState(0);
const isTextArray = Array.isArray(text);
useEffect(() => {
@@ -33,7 +34,11 @@ export const ContentLoader = ({ text, frequency = 2000, className }: Props) => {
className
)}
>
<Lottie isAutoPlay icon="infisical_loading" className="h-32 w-32" />
<Lottie
isAutoPlay
icon="infisical_loading"
className={twMerge("h-32 w-32", lottieClassName)}
/>
{text && isTextArray && (
<AnimatePresence mode="wait">
<motion.div
@@ -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}
/>
@@ -16,6 +16,7 @@ export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "
center?: boolean;
size?: "sm" | "md";
rootProps?: RootProps;
delayDuration?: number;
};
export const Tooltip = ({
@@ -31,12 +32,13 @@ export const Tooltip = ({
position = "top",
size = "md",
rootProps,
delayDuration = 50,
...props
}: TooltipProps) =>
// just render children if tooltip content is empty
content ? (
<TooltipPrimitive.Root
delayDuration={50}
delayDuration={delayDuration}
{...rootProps}
open={isOpen}
defaultOpen={defaultOpen}
+32
View File
@@ -0,0 +1,32 @@
import { faCode, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TAuditLogStream } from "@app/hooks/api/types";
import { DiscriminativePick } from "@app/types";
export const AUDIT_LOG_STREAM_PROVIDER_MAP: Record<
LogProvider,
{ name: string; image?: string; icon?: IconDefinition; size?: number }
> = {
[LogProvider.Custom]: { name: "Custom", icon: faCode },
[LogProvider.Datadog]: { name: "Datadog", image: "Datadog.png" },
[LogProvider.Splunk]: { name: "Splunk", image: "Splunk.png", size: 65 }
};
// Strictly for showing to the client in the front-end
export function getProviderUrl(
logStream: DiscriminativePick<TAuditLogStream, "provider" | "credentials">
) {
switch (logStream.provider) {
case LogProvider.Custom:
return logStream.credentials.url;
case LogProvider.Datadog:
return logStream.credentials.url;
case LogProvider.Splunk:
return `https://${logStream.credentials.hostname}:8088/services/collector/event`;
default:
throw new Error(
`Unhandled provider in getProviderUrl: ${(logStream as TAuditLogStream).provider}`
);
}
}
+12
View File
@@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
+60 -1
View File
@@ -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,9 +9,12 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
import {
RootKeyEncryptionStrategy,
TCreateAdminUserDTO,
TCreateOrganizationDTO,
TInvalidateCacheDTO,
TResendOrgInviteDTO,
TServerConfig,
TUpdateServerConfigDTO
TUpdateServerConfigDTO,
TUsageReportResponse
} from "./types";
export const useCreateAdminUser = () => {
@@ -193,3 +197,58 @@ 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 () => {
const { data } = await apiRequest.post<TUsageReportResponse>(
"/api/v1/admin/usage-report/generate"
);
return data;
}
});
};
+24 -5
View File
@@ -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
});
};
+20
View File
@@ -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;
}[];
@@ -53,6 +56,7 @@ export type TServerConfig = {
fipsEnabled: boolean;
envOverrides?: Record<string, string>;
paramsFolderSecretDetectionEnabled: boolean;
isOfflineUsageReportsEnabled: boolean;
};
export type TUpdateServerConfigDTO = {
@@ -142,3 +146,19 @@ export interface TGetEnvOverrides {
fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[];
};
}
export type TUsageReportResponse = {
filename: string;
csvContent: string;
signature: string;
};
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
@@ -0,0 +1,5 @@
export enum LogProvider {
Datadog = "datadog",
Splunk = "splunk",
Custom = "custom"
}
@@ -1,6 +1,2 @@
export {
useCreateAuditLogStream,
useDeleteAuditLogStream,
useUpdateAuditLogStream
} from "./mutations";
export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries";
export * from "./mutations";
export * from "./queries";
@@ -12,50 +12,54 @@ import {
export const useCreateAuditLogStream = () => {
const queryClient = useQueryClient();
return useMutation<{ auditLogStream: TAuditLogStream }, object, TCreateAuditLogStreamDTO>({
mutationFn: async (dto) => {
return useMutation({
mutationFn: async ({ provider, ...params }: TCreateAuditLogStreamDTO) => {
const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>(
"/api/v1/audit-log-streams",
dto
`/api/v1/audit-log-streams/${provider}`,
params
);
return data;
return data.auditLogStream;
},
onSuccess: (_, { orgId }) => {
queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) });
}
onSuccess: () => queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() })
});
};
export const useUpdateAuditLogStream = () => {
const queryClient = useQueryClient();
return useMutation<{ auditLogStream: TAuditLogStream }, object, TUpdateAuditLogStreamDTO>({
mutationFn: async (dto) => {
return useMutation({
mutationFn: async ({ auditLogStreamId, provider, ...params }: TUpdateAuditLogStreamDTO) => {
const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>(
`/api/v1/audit-log-streams/${dto.id}`,
dto
`/api/v1/audit-log-streams/${provider}/${auditLogStreamId}`,
params
);
return data;
return data.auditLogStream;
},
onSuccess: (_, { orgId }) => {
queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) });
onSuccess: (_, { auditLogStreamId, provider }) => {
queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() });
queryClient.invalidateQueries({
queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId)
});
}
});
};
export const useDeleteAuditLogStream = () => {
const queryClient = useQueryClient();
return useMutation<{ auditLogStream: TAuditLogStream }, object, TDeleteAuditLogStreamDTO>({
mutationFn: async (dto) => {
return useMutation({
mutationFn: async ({ auditLogStreamId, provider }: TDeleteAuditLogStreamDTO) => {
const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>(
`/api/v1/audit-log-streams/${dto.id}`
`/api/v1/audit-log-streams/${provider}/${auditLogStreamId}`
);
return data;
return data.auditLogStream;
},
onSuccess: (_, { orgId }) => {
queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) });
onSuccess: (_, { auditLogStreamId, provider }) => {
queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() });
queryClient.invalidateQueries({
queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId)
});
}
});
};
@@ -1,40 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TAuditLogStream } from "./types";
import { TAuditLogStreamProviderOption } from "./types/provider-options";
import { LogProvider } from "./enums";
import { TAuditLogStream, TAuditLogStreamProviderMap } from "./types";
export const auditLogStreamKeys = {
list: (orgId: string) => ["audit-log-stream", { orgId }],
getById: (id: string) => ["audit-log-stream-details", { id }]
all: ["audit-log-stream"] as const,
options: () => [...auditLogStreamKeys.all, "options"] as const,
list: () => [...auditLogStreamKeys.all, "list"] as const,
getById: (provider: string, id: string) =>
[...auditLogStreamKeys.all, provider, "get-by-id", id] as const
};
const fetchAuditLogStreams = async () => {
const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>(
"/api/v1/audit-log-streams"
);
export const useGetAuditLogStreamOptions = (
options?: Omit<
UseQueryOptions<
TAuditLogStreamProviderOption[],
unknown,
TAuditLogStreamProviderOption[],
ReturnType<typeof auditLogStreamKeys.options>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: auditLogStreamKeys.options(),
queryFn: async () => {
const { data } = await apiRequest.get<{ providerOptions: TAuditLogStreamProviderOption[] }>(
"/api/v1/audit-log-streams/options"
);
return data.auditLogStreams;
};
export const useGetAuditLogStreams = (orgId: string) =>
useQuery({
queryKey: auditLogStreamKeys.list(orgId),
queryFn: () => fetchAuditLogStreams(),
enabled: Boolean(orgId)
return data.providerOptions;
},
...options
});
const fetchAuditLogStreamDetails = async (id: string) => {
const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStream }>(
`/api/v1/audit-log-streams/${id}`
);
return data.auditLogStream;
};
export const useGetAuditLogStreamDetails = (id: string) =>
useQuery({
queryKey: auditLogStreamKeys.getById(id),
queryFn: () => fetchAuditLogStreamDetails(id),
enabled: Boolean(id)
export const useListAuditLogStreams = (
options?: Omit<
UseQueryOptions<
TAuditLogStream[],
unknown,
TAuditLogStream[],
ReturnType<typeof auditLogStreamKeys.list>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: auditLogStreamKeys.list(),
queryFn: async () => {
const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>(
"/api/v1/audit-log-streams"
);
return data.auditLogStreams;
},
...options
});
};
export const useGetAuditLogStreamById = <T extends LogProvider>(
provider: T,
logStreamId: string,
options?: Omit<
UseQueryOptions<
TAuditLogStreamProviderMap[T],
unknown,
TAuditLogStreamProviderMap[T],
ReturnType<typeof auditLogStreamKeys.getById>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: auditLogStreamKeys.getById(provider, logStreamId),
queryFn: async () => {
const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStreamProviderMap[T] }>(
`/api/v1/audit-log-streams/${provider}/${logStreamId}`
);
return data.auditLogStream;
},
...options
});
};
@@ -1,28 +0,0 @@
export type LogStreamHeaders = {
key: string;
value: string;
};
export type TAuditLogStream = {
id: string;
url: string;
headers?: LogStreamHeaders[];
};
export type TCreateAuditLogStreamDTO = {
url: string;
headers?: LogStreamHeaders[];
orgId: string;
};
export type TUpdateAuditLogStreamDTO = {
id: string;
url?: string;
headers?: LogStreamHeaders[];
orgId: string;
};
export type TDeleteAuditLogStreamDTO = {
id: string;
orgId: string;
};
@@ -0,0 +1,25 @@
import { LogProvider } from "../enums";
import { TCustomProviderLogStream } from "./providers/custom-provider";
import { TDatadogProviderLogStream } from "./providers/datadog-provider";
import { TSplunkProviderLogStream } from "./providers/splunk-provider";
export type TAuditLogStream =
| TCustomProviderLogStream
| TDatadogProviderLogStream
| TSplunkProviderLogStream;
export type TAuditLogStreamProviderMap = {
[LogProvider.Custom]: TCustomProviderLogStream;
[LogProvider.Datadog]: TDatadogProviderLogStream;
[LogProvider.Splunk]: TSplunkProviderLogStream;
};
export type TCreateAuditLogStreamDTO = Pick<TAuditLogStream, "provider" | "credentials">;
export type TUpdateAuditLogStreamDTO = Pick<TAuditLogStream, "credentials"> & {
provider: LogProvider;
auditLogStreamId: string;
};
export type TDeleteAuditLogStreamDTO = {
provider: LogProvider;
auditLogStreamId: string;
};
@@ -0,0 +1,11 @@
import { LogProvider } from "../enums";
export type TAuditLogStreamProviderOptionBase = {
name: string;
};
export type TAuditLogStreamProviderOption = {
[P in keyof typeof LogProvider]: TAuditLogStreamProviderOptionBase & {
provider: (typeof LogProvider)[P];
};
}[keyof typeof LogProvider];
@@ -0,0 +1,10 @@
import { LogProvider } from "../../enums";
import { TRootProviderLogStream } from "./root-provider";
export type TCustomProviderLogStream = TRootProviderLogStream & {
provider: LogProvider.Custom;
credentials: {
url: string;
headers: { key: string; value: string }[];
};
};
@@ -0,0 +1,10 @@
import { LogProvider } from "../../enums";
import { TRootProviderLogStream } from "./root-provider";
export type TDatadogProviderLogStream = TRootProviderLogStream & {
provider: LogProvider.Datadog;
credentials: {
url: string;
token: string;
};
};
@@ -0,0 +1,6 @@
export type TRootProviderLogStream = {
id: string;
orgId: string;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,10 @@
import { LogProvider } from "../../enums";
import { TRootProviderLogStream } from "./root-provider";
export type TSplunkProviderLogStream = TRootProviderLogStream & {
provider: LogProvider.Splunk;
credentials: {
hostname: string;
token: string;
};
};
@@ -1 +1,2 @@
export * from "./mutations";
export * from "./queries";
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
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"
}
@@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { useOrganization } from "@app/context";
import { notificationKeys } from "./queries";
import { TUserNotification } from "./types";
export const useMarkAllNotificationsAsRead = () => {
const { currentOrg } = useOrganization();
const orgId = currentOrg.id || "";
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await apiRequest.post("/api/v1/notifications/user/mark-as-read");
},
onSuccess: () => {
queryClient.setQueryData<TUserNotification[]>(notificationKeys.list(orgId), (oldData) => {
if (!oldData) return oldData;
return oldData.map((notification) => ({
...notification,
isRead: true
}));
});
}
});
};
export const useUpdateNotification = () => {
const { currentOrg } = useOrganization();
const orgId = currentOrg.id || "";
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ notificationId, isRead }: { notificationId: string; isRead: boolean }) => {
const { data } = await apiRequest.patch<{ notification: TUserNotification }>(
`/api/v1/notifications/user/${notificationId}`,
{ isRead }
);
return data.notification;
},
onSuccess: (updatedNotification) => {
queryClient.setQueryData<TUserNotification[]>(notificationKeys.list(orgId), (oldData) => {
if (!oldData) return oldData;
return oldData.map((notification) =>
notification.id === updatedNotification.id ? updatedNotification : notification
);
});
}
});
};
export const useDeleteNotification = () => {
const { currentOrg } = useOrganization();
const orgId = currentOrg.id || "";
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (notificationId: string) => {
await apiRequest.delete(`/api/v1/notifications/user/${notificationId}`);
},
onSuccess: (_, notificationId) => {
queryClient.setQueryData<TUserNotification[]>(notificationKeys.list(orgId), (oldData) => {
if (!oldData) return oldData;
return oldData.filter((notification) => notification.id !== notificationId);
});
}
});
};
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { useOrganization } from "@app/context";
import { TUserNotification } from "./types";
export const notificationKeys = {
all: ["notifications"] as const,
list: (orgId: string) => [...notificationKeys.all, "list", { orgId }] as const
};
export const useGetMyNotifications = () => {
const { currentOrg } = useOrganization();
const orgId = currentOrg.id || "";
return useQuery({
queryKey: notificationKeys.list(orgId),
queryFn: async () => {
const {
data: { notifications }
} = await apiRequest.get<{ notifications: TUserNotification[] }>(
"/api/v1/notifications/user"
);
return notifications;
},
refetchInterval: 30 * 1000 // Poll every 30 seconds
});
};
@@ -0,0 +1,10 @@
export interface TUserNotification {
id: string;
userId: string;
type: string;
title: string;
body?: string | null;
link?: string | null;
isRead: boolean;
createdAt: string;
}
@@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}
export enum OrgMembershipStatus {
Invited = "invited",
Accepted = "accepted"
}
+3 -3
View File
@@ -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">
+64 -61
View File
@@ -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>
);
@@ -1,12 +1,10 @@
import { useOrganization } from "@app/context";
import { useFetchServerStatus, useGetAuditLogStreams } from "@app/hooks/api";
import { useFetchServerStatus, useListAuditLogStreams } from "@app/hooks/api";
import { OrgAlertBanner } from "../OrgAlertBanner";
export const AuditLogBanner = () => {
const org = useOrganization();
const { data: status, isLoading: isLoadingStatus } = useFetchServerStatus();
const { data: streams, isLoading: isLoadingStreams } = useGetAuditLogStreams(org.currentOrg.id);
const { data: streams, isLoading: isLoadingStreams } = useListAuditLogStreams();
if (isLoadingStreams || isLoadingStatus || !streams) return null;
@@ -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";
@@ -47,6 +48,7 @@ import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
import { NotificationDropdown } from "./NotificationDropdown";
const getPlan = (subscription: SubscriptionPlan) => {
if (subscription.groups) return "Enterprise";
@@ -118,6 +120,7 @@ export const Navbar = () => {
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 +182,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,100 +192,121 @@ 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>
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2 py-1 hover:bg-mineshaft-600">
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
<FontAwesomeIcon icon={faCircleQuestion} className="text-mineshaft-200" />
</div>
</DropdownMenuTrigger>
@@ -334,9 +360,10 @@ export const Navbar = () => {
)}
</DropdownMenuContent>
</DropdownMenu>
<NotificationDropdown />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<div className="rounded-r-md border border-mineshaft-500 px-2 py-1 hover:bg-mineshaft-600">
<div className="rounded-r-md border border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
<FontAwesomeIcon icon={faUserCircle} className="text-mineshaft-200" />
</div>
</DropdownMenuTrigger>
@@ -0,0 +1,60 @@
import Markdown from "react-markdown";
import { faCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { IconButton, Tooltip } from "@app/components/v2";
import { TUserNotification } from "@app/hooks/api/notifications/types";
type Props = {
notification: TUserNotification;
onDelete: (notificationId: string) => void;
};
export const Notification = ({ notification, onDelete }: Props) => {
return (
<div
className={twMerge(
"group relative flex cursor-pointer items-start border-b border-mineshaft-600 p-3 transition-colors",
notification.link ? "hover:bg-mineshaft-700" : "cursor-default",
!notification.isRead && "bg-mineshaft-800"
)}
>
<div className="flex w-full min-w-0 flex-col">
<div className="flex gap-2">
{!notification.isRead && (
<FontAwesomeIcon icon={faCircle} className="mt-1.5 size-2 text-yellow-400" />
)}
<Tooltip content={<Markdown>{notification.title}</Markdown>} delayDuration={300}>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5 text-mineshaft-100">
<Markdown components={{ p: "span" }}>{notification.title}</Markdown>
</span>
</Tooltip>
<span className="ml-auto mt-px whitespace-nowrap text-xs text-mineshaft-400">
{formatDistance(notification.createdAt, new Date())} ago
</span>
</div>
{notification.body && (
<span className="max-w-[350px] text-xs text-mineshaft-300">
<Markdown>{notification.body}</Markdown>
</span>
)}
</div>
<div className="flex w-0 flex-shrink-0 justify-end opacity-0 transition-all group-hover:w-[24px] group-hover:opacity-100">
<IconButton
ariaLabel="delete"
variant="plain"
colorSchema="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(notification.id);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import { useMemo } from "react";
import { faBell } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "@tanstack/react-router";
import {
ContentLoader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/v2";
import {
useDeleteNotification,
useMarkAllNotificationsAsRead,
useUpdateNotification
} from "@app/hooks/api/notifications/mutations";
import { useGetMyNotifications } from "@app/hooks/api/notifications/queries";
import { Notification } from "./Notification";
export const NotificationDropdown = () => {
const router = useRouter();
const { data: notifications, isLoading } = useGetMyNotifications();
const { mutate: markAllAsRead } = useMarkAllNotificationsAsRead();
const { mutate: updateNotification } = useUpdateNotification();
const { mutate: deleteNotification } = useDeleteNotification();
const unreadCount = useMemo(
() => notifications?.filter((n) => !n.isRead).length || 0,
[notifications]
);
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<div className="relative border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
<FontAwesomeIcon icon={faBell} className="text-mineshaft-200" />
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-yellow-400 px-1 text-[10px] text-black">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
className="mt-3 flex h-[550px] w-[400px] overflow-hidden rounded-lg"
>
<div className="flex w-full flex-col">
<div className="flex items-center justify-between border-b border-mineshaft-500 px-3 py-2">
<span className="font-semibold text-white">Notifications</span>
<button
type="button"
className="text-xs font-medium text-mineshaft-300 hover:text-primary-400 disabled:pointer-events-none disabled:opacity-50"
onClick={(e) => {
e.preventDefault();
markAllAsRead();
}}
disabled={unreadCount === 0}
>
Mark all as read
</button>
</div>
<div className="flex h-full w-full overflow-auto">
{isLoading && (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader className="pointer-events-none" lottieClassName="size-10" />
</div>
)}
{!isLoading && notifications?.length === 0 && (
<div className="flex h-full w-full flex-col items-center justify-center">
<FontAwesomeIcon icon={faBell} size="3x" className="text-mineshaft-400" />
<span className="mt-4 text-sm text-mineshaft-300">No new notifications</span>
<span className="text-xs text-mineshaft-400">
We&apos;ll let you know when something important happens.
</span>
</div>
)}
{!isLoading && notifications && notifications.length > 0 && (
<div className="flex w-full flex-col">
{notifications.map((notification) => (
<div
role="button"
tabIndex={0}
key={notification.id}
onClick={() => {
if (!notification.isRead) {
updateNotification({ notificationId: notification.id, isRead: true });
}
if (notification.link) {
router.navigate({ to: notification.link });
}
}}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
if (!notification.isRead) {
updateNotification({ notificationId: notification.id, isRead: true });
}
if (notification.link) {
router.navigate({ to: notification.link });
}
}}
>
<Notification notification={notification} onDelete={deleteNotification} />
</div>
))}
</div>
)}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -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"
/>
@@ -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";
@@ -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"
})
}
]
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { useGetServerConfig } from "@app/hooks/api/admin";
import { GeneralPageForm } from "./components";
import { GeneralPageForm, UsageReportSection } from "./components";
export const GeneralPage = () => {
const { t } = useTranslation();
const { data: serverConfig } = useGetServerConfig();
return (
<div className="h-full bg-bunker-800">
@@ -19,7 +21,10 @@ export const GeneralPage = () => {
title="General"
description="Manage general settings for your Infisical instance."
/>
<GeneralPageForm />
<div className="space-y-6">
<GeneralPageForm />
{serverConfig?.isOfflineUsageReportsEnabled && <UsageReportSection />}
</div>
</div>
</div>
</div>
@@ -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">
@@ -0,0 +1,53 @@
import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, Card, CardTitle } from "@app/components/v2";
import { downloadFile } from "@app/helpers/download";
import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation";
export const UsageReportSection = () => {
const generateUsageReport = useGenerateUsageReport();
const handleGenerateReport = async () => {
try {
const response = await generateUsageReport.mutateAsync();
const { csvContent, filename } = response;
downloadFile(csvContent, filename, "text/csv");
createNotification({
text: `Usage report downloaded: "${filename}"`,
type: "success"
});
} catch (error) {
console.error("Failed to generate usage report:", error);
createNotification({
text: "Failed to generate usage report. Please try again.",
type: "error"
});
}
};
return (
<Card className="p-6">
<CardTitle className="mb-4 flex items-center gap-3">
<FontAwesomeIcon icon={faFileAlt} />
Offline Usage Reports
</CardTitle>
<div className="mb-4 text-sm text-gray-400">
Generate secure usage reports for offline license compliance and billing verification.
</div>
<Button
onClick={handleGenerateReport}
className="w-fit"
isLoading={generateUsageReport.isPending}
leftIcon={<FontAwesomeIcon icon={generateUsageReport.isPending ? faSpinner : faDownload} />}
>
{generateUsageReport.isPending ? "Generating..." : "Generate Report"}
</Button>
</Card>
);
};
@@ -1 +1,2 @@
export { GeneralPageForm } from "./GeneralPageForm";
export { UsageReportSection } from "./UsageReportSection";
@@ -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>
);
};
@@ -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>
);
};
@@ -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";
@@ -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";
@@ -88,7 +88,15 @@ export const SelectOrganizationSection = () => {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
if (organization.googleSsoAuthEnforced) {
if (authToken.authMethod !== AuthMethod.GOOGLE) {
url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`;
if (callbackPort) {
url += `&callback_port=${callbackPort}`;
}
}
} else if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
@@ -98,15 +106,6 @@ export const SelectOrganizationSection = () => {
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
} else if (
organization.googleSsoAuthEnforced &&
authToken.authMethod !== AuthMethod.GOOGLE
) {
url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`;
if (callbackPort) {
url += `&callback_port=${callbackPort}`;
}
}
// we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO
@@ -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") ||
@@ -1,207 +0,0 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, Spinner } from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useCreateAuditLogStream,
useGetAuditLogStreamDetails,
useUpdateAuditLogStream
} from "@app/hooks/api";
type Props = {
id?: string;
onClose: () => void;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const formSchema = z.object({
url: z.string().url().min(1),
headers: z
.object({
key: z.string(),
value: z.string()
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
export const AuditLogStreamForm = ({ id = "", onClose }: Props) => {
const isEdit = Boolean(id);
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const auditLogStream = useGetAuditLogStreamDetails(id);
const createAuditLogStream = useCreateAuditLogStream();
const updateAuditLogStream = useUpdateAuditLogStream();
const {
handleSubmit,
control,
setValue,
getValues,
formState: { isSubmitting }
} = useForm<TForm>({
values: auditLogStream?.data,
defaultValues: {
headers: [{ key: "", value: "" }]
}
});
const headerFields = useFieldArray({
control,
name: "headers"
});
const handleAuditLogStreamEdit = async ({ headers, url }: TForm) => {
if (!id) return;
try {
await updateAuditLogStream.mutateAsync({
id,
orgId,
headers,
url
});
createNotification({
type: "success",
text: "Successfully updated stream"
});
onClose();
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update stream"
});
}
};
const handleFormSubmit = async ({ headers = [], url }: TForm) => {
if (isSubmitting) return;
const sanitizedHeaders = headers.filter(({ key, value }) => Boolean(key) && Boolean(value));
const streamHeaders = sanitizedHeaders.length ? sanitizedHeaders : undefined;
if (isEdit) {
await handleAuditLogStreamEdit({ headers: streamHeaders, url });
return;
}
try {
await createAuditLogStream.mutateAsync({
orgId,
headers: streamHeaders,
url
});
createNotification({
type: "success",
text: "Successfully created stream"
});
onClose();
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: (err as Error)?.message ?? "Failed to create stream"
});
}
};
if (isEdit && auditLogStream.isPending) {
return (
<div className="flex items-center justify-center p-8">
<Spinner size="lg" />
</div>
);
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Endpoint URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<FormLabel label="Headers" isOptional />
{headerFields.fields.map(({ id: headerFieldId }, i) => (
<div key={headerFieldId} className="flex space-x-2">
<Controller
control={control}
name={`headers.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-1/3"
>
<Input {...field} placeholder="Authorization" />
</FormControl>
)}
/>
<Controller
control={control}
name={`headers.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
autoComplete="new-password"
/>
</FormControl>
)}
/>
<IconButton
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
const header = getValues("headers");
if (header && header?.length > 1) {
headerFields.remove(i);
} else {
setValue("headers", [{ key: "", value: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => headerFields.append({ value: "", key: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
</Button>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,125 @@
import { createNotification } from "@app/components/notifications";
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
import { useCreateAuditLogStream, useUpdateAuditLogStream } from "@app/hooks/api";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TAuditLogStream } from "@app/hooks/api/types";
import { DiscriminativePick } from "@app/types";
import { AuditLogStreamHeader } from "../components/AuditLogStreamHeader";
import { CustomProviderAuditLogStreamForm } from "./CustomProviderAuditLogStreamForm";
import { DatadogProviderAuditLogStreamForm } from "./DatadogProviderAuditLogStreamForm";
import { SplunkProviderAuditLogStreamForm } from "./SplunkProviderAuditLogStreamForm";
type FormProps = {
onComplete: (auditLogStream: TAuditLogStream) => void;
};
type CreateFormProps = FormProps & { provider: LogProvider };
type UpdateFormProps = FormProps & {
auditLogStream: TAuditLogStream;
};
const CreateForm = ({ provider, onComplete }: CreateFormProps) => {
const createAuditLogStream = useCreateAuditLogStream();
const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
const onSubmit = async (
formData: DiscriminativePick<TAuditLogStream, "provider" | "credentials">
) => {
try {
const logStream = await createAuditLogStream.mutateAsync(formData);
createNotification({
text: `Successfully created ${providerName} Log Stream`,
type: "success"
});
onComplete(logStream);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to create ${providerName} Log Stream`,
text: err.message,
type: "error"
});
}
};
switch (provider) {
case LogProvider.Custom:
return <CustomProviderAuditLogStreamForm onSubmit={onSubmit} />;
case LogProvider.Datadog:
return <DatadogProviderAuditLogStreamForm onSubmit={onSubmit} />;
case LogProvider.Splunk:
return <SplunkProviderAuditLogStreamForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled Provider: ${provider}`);
}
};
const UpdateForm = ({ auditLogStream, onComplete }: UpdateFormProps) => {
const updateAuditLogStream = useUpdateAuditLogStream();
const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider];
const onSubmit = async (
formData: DiscriminativePick<TAuditLogStream, "provider" | "credentials">
) => {
try {
const connection = await updateAuditLogStream.mutateAsync({
auditLogStreamId: auditLogStream.id,
...formData
});
createNotification({
text: `Successfully updated ${providerName} Log Stream`,
type: "success"
});
onComplete(connection);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${providerName} Log Stream`,
text: err.message,
type: "error"
});
}
};
switch (auditLogStream.provider) {
case LogProvider.Custom:
return (
<CustomProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
case LogProvider.Datadog:
return (
<DatadogProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
case LogProvider.Splunk:
return (
<SplunkProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
default:
throw new Error(`Unhandled Provider: ${(auditLogStream as TAuditLogStream).provider}`);
}
};
type Props = { onBack?: () => void } & Pick<FormProps, "onComplete"> &
(
| { provider: LogProvider; auditLogStream?: undefined }
| { provider?: undefined; auditLogStream: TAuditLogStream }
);
export const AuditLogStreamForm = ({ onBack, ...props }: Props) => {
const { provider, auditLogStream } = props;
return (
<div>
<AuditLogStreamHeader
logStreamExists={Boolean(auditLogStream)}
provider={auditLogStream ? auditLogStream.provider : provider}
onBack={onBack}
/>
{auditLogStream ? (
<UpdateForm {...props} auditLogStream={auditLogStream} />
) : (
<CreateForm {...props} provider={provider} />
)}
</div>
);
};
@@ -0,0 +1,176 @@
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider";
type Props = {
auditLogStream?: TCustomProviderLogStream;
onSubmit: (formData: FormData) => void;
};
const formSchema = z.object({
provider: z.literal(LogProvider.Custom),
credentials: z.object({
url: z.string().url().trim().min(1).max(255),
headers: z
.object({
key: z.string().min(1),
value: z.string().min(1)
})
.array()
})
});
type FormData = z.infer<typeof formSchema>;
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: auditLogStream ?? {
provider: LogProvider.Custom
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty },
getValues,
setValue
} = form;
const headerFields = useFieldArray({
control,
name: "credentials.headers"
});
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.url"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Endpoint URL"
>
<Input {...field} placeholder="https://example.com" />
</FormControl>
)}
/>
<FormLabel label="Headers" isOptional />
{headerFields.fields.map(({ id: headerFieldId }, i) => (
<div key={headerFieldId} className="flex space-x-2">
<Controller
control={control}
name={`credentials.headers.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-1/3"
>
<Input {...field} placeholder="Authorization" />
</FormControl>
)}
/>
<Controller
control={control}
name={`credentials.headers.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
autoComplete="new-password"
onFocus={(e) => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
auditLogStream.credentials.headers[i].value === "******" &&
field.value === "******"
) {
field.onChange("");
}
e.target.type = "text";
}}
onBlur={(e) => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
auditLogStream.credentials.headers[i].value === "******" &&
field.value === ""
) {
field.onChange("******");
}
e.target.type = "password";
}}
/>
</FormControl>
)}
/>
<IconButton
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
const header = getValues("credentials.headers");
if (header && header?.length > 1) {
headerFields.remove(i);
} else {
setValue("credentials.headers", [{ key: "", value: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => headerFields.append({ value: "", key: "" })}
>
Add Key
</Button>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,132 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TDatadogProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/datadog-provider";
type Props = {
auditLogStream?: TDatadogProviderLogStream;
onSubmit: (formData: FormData) => void;
};
const formSchema = z.object({
provider: z.literal(LogProvider.Datadog),
credentials: z.object({
url: z.string().url().trim().min(1).max(255),
token: z
.string()
.trim()
.regex(/^[a-fA-F0-9]{32}$/, "Invalid Datadog API key format")
})
});
type FormData = z.infer<typeof formSchema>;
const DATADOG_ENDPOINTS = {
"Datadog US1": "https://http-intake.logs.datadoghq.com/api/v2/logs",
"Datadog US3": "https://http-intake.logs.us3.datadoghq.com/api/v2/logs",
"Datadog US5": "https://http-intake.logs.us5.datadoghq.com/api/v2/logs",
"Datadog EU": "https://http-intake.logs.datadoghq.eu/api/v2/logs",
"Datadog AP1": "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs",
"Datadog AP2": "https://http-intake.logs.ap2.datadoghq.com/api/v2/logs",
"Datadog GovCloud (US1-FED)": "https://http-intake.logs.ddog-gov.com/api/v2/logs"
};
export const DatadogProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: auditLogStream ?? {
provider: LogProvider.Datadog,
credentials: {
url: DATADOG_ENDPOINTS["Datadog US1"]
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.url"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Datadog Region"
helperText={value}
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.entries(DATADOG_ENDPOINTS).map(([k, v]) => {
return (
<SelectItem value={v} key={k}>
{k}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.token"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Datadog Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,120 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose, SecretInput } from "@app/components/v2";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TSplunkProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/splunk-provider";
type Props = {
auditLogStream?: TSplunkProviderLogStream;
onSubmit: (formData: FormData) => void;
};
const formSchema = z.object({
provider: z.literal(LogProvider.Splunk),
credentials: z.object({
hostname: z
.string()
.trim()
.min(1)
.max(255)
.superRefine((val, ctx) => {
if (val.includes("://")) {
ctx.addIssue({
code: "custom",
message: "Hostname should not include protocol"
});
return;
}
try {
const url = new URL(`https://${val}`);
if (url.hostname !== val) {
ctx.addIssue({
code: "custom",
message: "Must be a valid hostname without port or path"
});
}
} catch {
ctx.addIssue({ code: "custom", message: "Invalid hostname" });
}
}),
token: z.string().uuid().trim().min(1)
})
});
type FormData = z.infer<typeof formSchema>;
export const SplunkProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: auditLogStream ?? {
provider: LogProvider.Splunk
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.hostname"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Hostname"
>
<Input {...field} placeholder="splunk.example.com" />
</FormControl>
)}
/>
<Controller
name="credentials.token"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Splunk Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -1,72 +1,24 @@
import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteAuditLogStream, useGetAuditLogStreams } from "@app/hooks/api";
import { AuditLogStreamForm } from "./AuditLogStreamForm";
import { AuditLogStreamTable } from "./components/AuditLogStreamTable";
import { AddAuditLogStreamModal } from "./components";
export const AuditLogStreamsTab = withPermission(
() => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"auditLogStreamForm",
"deleteAuditLogStream",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { data: auditLogStreams, isPending: isAuditLogStreamsLoading } =
useGetAuditLogStreams(orgId);
// mutation
const { mutateAsync: deleteAuditLogStream } = useDeleteAuditLogStream();
const handleAuditLogStreamDelete = async () => {
try {
const auditLogStreamId = popUp?.deleteAuditLogStream?.data as string;
await deleteAuditLogStream({
id: auditLogStreamId,
orgId
});
handlePopUpClose("deleteAuditLogStream");
createNotification({
type: "success",
text: "Successfully deleted stream"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete stream"
});
}
};
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"auditLogStreamForm",
"upgradePlan"
] as const);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -84,8 +36,10 @@ export const AuditLogStreamsTab = withPermission(
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
variant="outline_bg"
colorSchema="secondary"
>
Create
Add Log Stream
</Button>
)}
</OrgPermissionCan>
@@ -93,102 +47,15 @@ export const AuditLogStreamsTab = withPermission(
<p className="mb-8 text-gray-400">
Send audit logs from Infisical to external logging providers via HTTP
</p>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>URL</Td>
<Td className="text-right">Action</Td>
</Tr>
</THead>
<TBody>
{isAuditLogStreamsLoading && (
<TableSkeleton columns={2} innerKey="stream-loading" />
)}
{!isAuditLogStreamsLoading && auditLogStreams && auditLogStreams?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No audit log streams found" icon={faPlug} />
</Td>
</Tr>
)}
{!isAuditLogStreamsLoading &&
auditLogStreams?.map(({ id, url }) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("auditLogStreamForm", id)}
>
Edit
</Button>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteAuditLogStream", id)}
>
Delete
</Button>
)}
</OrgPermissionCan>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
<Modal
<AuditLogStreamTable />
<AddAuditLogStreamModal
isOpen={popUp.auditLogStreamForm.isOpen}
onOpenChange={(isModalOpen) => {
handlePopUpToggle("auditLogStreamForm", isModalOpen);
}}
>
<ModalContent
title={`${popUp?.auditLogStreamForm?.data ? "Update" : "Create"} Audit Log Stream `}
subTitle="Continuously stream logs from Infisical to third-party logging providers."
>
<AuditLogStreamForm
id={popUp?.auditLogStreamForm?.data as string}
onClose={() => handlePopUpToggle("auditLogStreamForm")}
/>
</ModalContent>
</Modal>
onOpenChange={(isOpen) => handlePopUpToggle("auditLogStreamForm", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
/>
<DeleteActionModal
isOpen={popUp.deleteAuditLogStream.isOpen}
deleteKey="delete"
title="Are you sure you want to remove this stream?"
onChange={(isOpen) => handlePopUpToggle("deleteAuditLogStream", isOpen)}
onClose={() => handlePopUpClose("deleteAuditLogStream")}
onDeleteApproved={handleAuditLogStreamDelete}
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
/>
</div>
);
@@ -0,0 +1,69 @@
import { Dispatch, SetStateAction, useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { TAuditLogStream } from "@app/hooks/api/types";
import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm";
import { LogStreamProviderSelect } from "./LogStreamProviderSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (auditLogStream: TAuditLogStream) => void;
selectedProvider: LogProvider | null;
setSelectedProvider: Dispatch<SetStateAction<LogProvider | null>>;
};
const Content = ({ onComplete, selectedProvider, setSelectedProvider }: ContentProps) => {
if (selectedProvider) {
return (
<AuditLogStreamForm
onComplete={onComplete}
onBack={() => setSelectedProvider(null)}
provider={selectedProvider}
/>
);
}
return <LogStreamProviderSelect onSelect={setSelectedProvider} />;
};
export const AddAuditLogStreamModal = ({ isOpen, onOpenChange }: Props) => {
const [selectedProvider, setSelectedProvider] = useState<LogProvider | null>(null);
return (
<Modal
isOpen={isOpen}
onOpenChange={(e) => {
onOpenChange(e);
if (!e) setSelectedProvider(null);
}}
>
<ModalContent
className="max-w-2xl"
title="Log Provider"
subTitle=<>
Select a log provider or{" "}
<button
type="button"
className="underline"
onClick={() => setSelectedProvider(LogProvider.Custom)}
>
input a custom URL
</button>{" "}
to stream logs to.
</>
>
<Content
onComplete={() => onOpenChange(false)}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,69 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
type Props = {
provider: LogProvider;
logStreamExists: boolean;
onBack?: () => void;
};
export const AuditLogStreamHeader = ({ provider, logStreamExists, onBack }: Props) => {
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
return (
<div className="mb-4 flex w-full items-center gap-2 border-b border-mineshaft-500 pb-4">
<div className="relative">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="size-12 rounded-md bg-bunker-500 p-2"
/>
) : (
providerDetails.icon && (
<div className="size-12 rounded-md bg-bunker-500 p-2">
<FontAwesomeIcon
icon={providerDetails.icon}
className="h-full w-full text-mineshaft-300"
/>
</div>
)
)}
</div>
<div>
<div className="mb-1 flex items-center text-mineshaft-300">
{providerDetails.name}
<a
href="https://infisical.com/docs/documentation/platform/audit-log-streams/audit-log-streams#example-providers"
target="_blank"
className="ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-px mr-1 text-xs" />
<span>Docs</span>
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="mb-px ml-1 text-[10px]" />
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{logStreamExists
? `${providerDetails.name} Log Stream`
: `Create a ${providerDetails.name} Log Stream`}
</p>
</div>
{onBack && (
<button
type="button"
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
onClick={onBack}
>
Select another provider
</button>
)}
</div>
);
};
@@ -0,0 +1,111 @@
import { faAsterisk, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionActions } from "@app/context/OrgPermissionContext/types";
import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams";
import { TAuditLogStream } from "@app/hooks/api/types";
type Props = {
logStream: TAuditLogStream;
onDelete: (logStream: TAuditLogStream) => void;
onEditCredentials: (logStream: TAuditLogStream) => void;
};
export const AuditLogStreamRow = ({ logStream, onDelete, onEditCredentials }: Props) => {
const { id, provider } = logStream;
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
const url = getProviderUrl(logStream);
return (
<Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
key={`log-stream-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<div className="relative">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="size-5"
/>
) : (
providerDetails.icon && (
<FontAwesomeIcon
icon={providerDetails.icon}
className="size-5 text-mineshaft-300"
/>
)
)}
</div>
<span className="hidden lg:inline">{providerDetails.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{url}</p>
</div>
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(logStream)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(logStream)}
>
Delete Stream
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
};
@@ -0,0 +1,304 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faFilter,
faMagnifyingGlass,
faPlug,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useListAuditLogStreams } from "@app/hooks/api";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TAuditLogStream } from "@app/hooks/api/types";
import { AuditLogStreamRow } from "./AuditLogStreamRow";
import { DeleteAuditLogStreamModal } from "./DeleteAuditLogStreamModal";
import { EditAuditLogStreamCredentialsModal } from "./EditAuditLogStreamCredentialsModal";
enum LogStreamsOrderBy {
Provider = "provider",
Url = "url"
}
type LogStreamFilters = {
providers: LogProvider[];
};
export const AuditLogStreamTable = () => {
const { isPending, data: logStreams = [] } = useListAuditLogStreams();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"delete",
"editCredentials"
] as const);
const [filters, setFilters] = useState<LogStreamFilters>({
providers: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<LogStreamsOrderBy>(LogStreamsOrderBy.Provider, {
initPerPage: getUserTablePreference("logStreamsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("logStreamsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredLogStreams = useMemo(
() =>
logStreams
.filter((stream) => {
const { provider } = stream;
if (filters.providers.length && !filters.providers.includes(provider)) return false;
const searchValue = search.trim().toLowerCase();
return AUDIT_LOG_STREAM_PROVIDER_MAP[provider].name.toLowerCase().includes(searchValue);
})
.sort((a, b) => {
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case LogStreamsOrderBy.Url:
return getProviderUrl(one)
.toLowerCase()
.localeCompare(getProviderUrl(two).toLowerCase());
case LogStreamsOrderBy.Provider:
default:
return AUDIT_LOG_STREAM_PROVIDER_MAP[one.provider].name
.toLowerCase()
.localeCompare(AUDIT_LOG_STREAM_PROVIDER_MAP[two.provider].name.toLowerCase());
}
}),
[logStreams, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredLogStreams.length,
offset,
setPage
});
const handleSort = (column: LogStreamsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: LogStreamsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: LogStreamsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.providers.length);
const handleDelete = (logStream: TAuditLogStream) => handlePopUpOpen("delete", logStream);
const handleEditCredentials = (logStream: TAuditLogStream) => {
handlePopUpOpen("editCredentials", logStream);
};
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search audit log streams..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Log Streams"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Filter by Provider</DropdownMenuLabel>
{logStreams.length ? (
[...new Set(logStreams.map(({ provider }) => provider))].map((provider) => {
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
providers: prev.providers.includes(provider)
? prev.providers.filter((a) => a !== provider)
: [...prev.providers, provider]
}));
}}
key={provider}
iconPos="right"
>
<div className="flex items-center gap-2">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="h-4 w-4"
/>
) : (
providerDetails.icon && (
<FontAwesomeIcon
icon={providerDetails.icon}
className="size-4 text-mineshaft-300"
/>
)
)}
<span>{providerDetails.name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Providers Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/4">
<div className="flex items-center">
Provider
<IconButton
variant="plain"
className={getClassName(LogStreamsOrderBy.Provider)}
ariaLabel="sort"
onClick={() => handleSort(LogStreamsOrderBy.Provider)}
>
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Provider)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Endpoint URL
<IconButton
variant="plain"
className={getClassName(LogStreamsOrderBy.Url)}
ariaLabel="sort"
onClick={() => handleSort(LogStreamsOrderBy.Url)}
>
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Url)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && (
<TableSkeleton
innerKey="audit-log-streams-table"
columns={3}
key="audit-log-streams"
/>
)}
{filteredLogStreams.slice(offset, perPage * page).map((stream) => (
<AuditLogStreamRow
logStream={stream}
key={stream.id}
onDelete={handleDelete}
onEditCredentials={handleEditCredentials}
/>
))}
</TBody>
</Table>
{Boolean(filteredLogStreams.length) && (
<Pagination
count={filteredLogStreams.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredLogStreams?.length && (
<EmptyState
title={
logStreams.length
? "No log streams match search..."
: "No log streams have been configured"
}
icon={logStreams.length ? faSearch : faPlug}
/>
)}
</TableContainer>
<DeleteAuditLogStreamModal
isOpen={popUp.delete.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("delete", isOpen)}
auditLogStream={popUp.delete.data}
/>
<EditAuditLogStreamCredentialsModal
isOpen={popUp.editCredentials.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
auditLogStream={popUp.editCredentials.data}
/>
</div>
);
};
@@ -0,0 +1,54 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
import { useDeleteAuditLogStream } from "@app/hooks/api";
import { TAuditLogStream } from "@app/hooks/api/types";
type Props = {
auditLogStream?: TAuditLogStream;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeleteAuditLogStreamModal = ({ isOpen, onOpenChange, auditLogStream }: Props) => {
const deleteAuditLogStream = useDeleteAuditLogStream();
if (!auditLogStream) return null;
const { id: auditLogStreamId, provider } = auditLogStream;
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
const handleDelete = async () => {
try {
await deleteAuditLogStream.mutateAsync({
auditLogStreamId,
provider
});
createNotification({
text: `Successfully deleted ${providerDetails.name} stream`,
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed to delete ${providerDetails.name} stream`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title="Are you sure you want to delete this log stream?"
deleteKey="delete"
onDeleteApproved={handleDelete}
/>
);
};
@@ -0,0 +1,34 @@
import { Modal, ModalContent } from "@app/components/v2";
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
import { TAuditLogStream } from "@app/hooks/api/types";
import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
auditLogStream?: TAuditLogStream;
};
export const EditAuditLogStreamCredentialsModal = ({
isOpen,
onOpenChange,
auditLogStream
}: Props) => {
if (!auditLogStream) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Log Stream Credentials"
subTitle={`Update the credentials for this ${AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider].name} Log Stream.`}
>
<AuditLogStreamForm
onComplete={() => onOpenChange(false)}
auditLogStream={auditLogStream}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,155 @@
import { useMemo } from "react";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, Spinner } from "@app/components/v2";
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetAuditLogStreamOptions } from "@app/hooks/api";
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
type Props = {
onSelect: (provider: LogProvider) => void;
};
// TODO: When we have more than 1 page of providers, uncomment the search components
export const LogStreamProviderSelect = ({ onSelect }: Props) => {
const { isPending, data: logStreamOptions } = useGetAuditLogStreamOptions();
const { search, setPage, page, perPage, offset } = usePagination("", {
initPerPage: 16
});
const filteredOptions = useMemo(
() =>
(logStreamOptions || [])
.filter(
({ name, provider }) =>
name.toLowerCase().includes(search.trim().toLowerCase()) ||
provider.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
if (a.provider === LogProvider.Custom) return 1;
if (b.provider === LogProvider.Custom) return -1;
return 0;
}),
[logStreamOptions, search]
);
useResetPageHelper({
totalCount: filteredOptions.length,
offset,
setPage
});
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* <Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search options..."
className="bg-mineshaft-800 placeholder:text-mineshaft-400"
/> */}
<div className="grid h-[29.5rem] grid-cols-4 content-start gap-2">
{filteredOptions.slice(offset, perPage * page)?.map((option) => {
const { image, icon, name, size = 50 } = AUDIT_LOG_STREAM_PROVIDER_MAP[option.provider];
return (
<button
type="button"
onClick={() => onSelect(option.provider)}
className={`group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 ${option.provider === LogProvider.Custom ? "bg-mineshaft-700/30 hover:bg-mineshaft-600/30" : "bg-mineshaft-700 hover:bg-mineshaft-600"} p-4 duration-200`}
>
{image && (
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
</div>
)}
{icon && (
<FontAwesomeIcon className="mt-auto size-10 text-mineshaft-300" icon={icon} />
)}
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
{!filteredOptions.length && (
<EmptyState
className="col-span-full mt-40"
title="No providers match search"
icon={faSearch}
/>
)}
</div>
{/* {Boolean(filteredOptions.length) && (
<Pagination
startAdornment={
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more providers.</p>
<p>
{`If you don't see the third-party
provider you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="-ml-3 flex items-center gap-1.5 text-mineshaft-400">
<span className="text-xs">
Don&#39;t see the third-party provider you&#39;re looking for?
</span>
<FontAwesomeIcon size="xs" icon={faInfoCircle} />
</div>
</Tooltip>
}
count={filteredOptions.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
perPageList={[16]}
/>
)} */}
</div>
);
};
@@ -0,0 +1 @@
export * from "./AddAuditLogStreamModal";
@@ -15,7 +15,9 @@ import {
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys } from "@app/hooks/api";
import { useHasCustomMigrationAvailable } from "@app/hooks/api/migration";
import { useImportVault } from "@app/hooks/api/migration/mutations";
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
@@ -204,9 +204,10 @@ export const OrgGeneralAuthSection = ({
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce users to authenticate via Google to access this organization.
Enforce users to authenticate via Google OAuth SSO to access this organization.
<br />
When this is enabled your organization members will only be able to login with Google.
When this is enabled your organization members will only be able to login with Google
SSO (not Google SAML).
</p>
</div>
</div>
@@ -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;
@@ -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;
@@ -29,6 +29,7 @@ import {
useWorkspace
} from "@app/context";
import {
ProjectPermissionCommitsActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions
} from "@app/context/ProjectPermissionContext/types";
@@ -106,7 +107,7 @@ const Page = () => {
});
const { permission } = useProjectPermission();
const { mutateAsync: createCommit } = useCreateCommit();
const { mutateAsync: createCommit, isPending: isCommitPending } = useCreateCommit();
const tableRef = useRef<HTMLTableElement>(null);
@@ -213,6 +214,11 @@ const Page = () => {
ProjectPermissionSub.SecretRollback
);
const canReadCommits = permission.can(
ProjectPermissionCommitsActions.Read,
ProjectPermissionSub.Commits
);
const defaultFilterState = {
tags: {},
searchFilter: (routerQueryParams.search as string) || "",
@@ -377,7 +383,7 @@ const Page = () => {
directory: secretPath,
workspaceId,
environment,
isPaused: !canDoReadRollback
isPaused: !canReadCommits
});
const {
@@ -997,7 +1003,7 @@ const Page = () => {
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isCommitting={false}
isCommitting={isCommitPending}
/>
)}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
@@ -75,7 +75,7 @@ type Props = {
onCreateTag: () => void;
environment: string;
secretPath: string;
handleSecretShare: () => void;
onShareSecret: (sec: SecretV3RawSanitized) => void;
importedBy?: {
environment: { name: string; slug: string };
folders: {
@@ -102,7 +102,7 @@ export const SecretItem = memo(
onToggleSecretSelect,
environment,
secretPath,
handleSecretShare,
onShareSecret,
importedBy,
isPending,
pendingAction,
@@ -682,7 +682,7 @@ export const SecretItem = memo(
variant="plain"
size="md"
ariaLabel="share-secret"
onClick={handleSecretShare}
onClick={() => onShareSecret(secret)}
>
<Tooltip content="Share Secret">
<FontAwesomeSymbol
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useEffect, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
@@ -100,6 +100,11 @@ export const SecretListView = ({
});
const { addPendingChange } = useBatchModeActions();
const pendingChangesRef = useRef(pendingChanges);
useEffect(() => {
pendingChangesRef.current = pendingChanges;
}, [pendingChanges]);
const handleSecretOperation = async (
operation: "create" | "update" | "delete",
type: SecretType,
@@ -337,7 +342,10 @@ export const SecretListView = ({
secretPath
});
} else {
const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets);
const trueOriginalSecret = getTrueOriginalSecret(
orgSecret,
pendingChangesRef.current.secrets
);
const updateChange: PendingSecretUpdate = {
id: orgSecret.id,
@@ -442,15 +450,7 @@ export const SecretListView = ({
});
}
},
[
environment,
secretPath,
isProtectedBranch,
isBatchMode,
workspaceId,
addPendingChange,
pendingChanges.secrets
]
[environment, secretPath, isProtectedBranch, isBatchMode, workspaceId, addPendingChange]
);
const handleSecretDelete = useCallback(async () => {
@@ -535,6 +535,13 @@ export const SecretListView = ({
(sec: SecretV3RawSanitized) => handlePopUpOpen("secretDetail", sec),
[]
);
const onShareSecret = useCallback(
(sec: SecretV3RawSanitized) =>
handlePopUpOpen("createSharedSecret", {
value: sec.valueOverride ?? sec.value
}),
[]
);
return (
<>
@@ -557,11 +564,7 @@ export const SecretListView = ({
onDetailViewSecret={onDetailViewSecret}
importedBy={importedBy}
onCreateTag={onCreateTag}
handleSecretShare={() =>
handlePopUpOpen("createSharedSecret", {
value: secret.valueOverride ?? secret.value
})
}
onShareSecret={onShareSecret}
isPending={secret.isPending}
pendingAction={secret.pendingAction}
/>
+49 -80
View File
@@ -47,15 +47,14 @@ import { Route as adminEnvironmentPageRouteImport } from './pages/admin/Environm
import { Route as adminEncryptionPageRouteImport } from './pages/admin/EncryptionPage/route'
import { Route as adminCachingPageRouteImport } from './pages/admin/CachingPage/route'
import { Route as adminAuthenticationPageRouteImport } from './pages/admin/AuthenticationPage/route'
import { Route as adminAccessManagementPageRouteImport } from './pages/admin/AccessManagementPage/route'
import { Route as organizationProjectsPageRouteImport } from './pages/organization/ProjectsPage/route'
import { Route as organizationBillingPageRouteImport } from './pages/organization/BillingPage/route'
import { Route as organizationAuditLogsPageRouteImport } from './pages/organization/AuditLogsPage/route'
import { Route as organizationAccessManagementPageRouteImport } from './pages/organization/AccessManagementPage/route'
import { Route as adminGeneralPageRouteImport } from './pages/admin/GeneralPage/route'
import { Route as secretManagerRedirectsRedirectApprovalPageImport } from './pages/secret-manager/redirects/redirect-approval-page'
import { Route as adminUserIdentitiesResourcesPageRouteImport } from './pages/admin/UserIdentitiesResourcesPage/route'
import { Route as adminOrganizationResourcesPageRouteImport } from './pages/admin/OrganizationResourcesPage/route'
import { Route as adminMachineIdentitiesResourcesPageRouteImport } from './pages/admin/MachineIdentitiesResourcesPage/route'
import { Route as adminResourceOverviewPageRouteImport } from './pages/admin/ResourceOverviewPage/route'
import { Route as organizationSecretSharingSettingsPageRouteImport } from './pages/organization/SecretSharingSettingsPage/route'
import { Route as organizationRoleByIDPageRouteImport } from './pages/organization/RoleByIDPage/route'
import { Route as organizationUserDetailsByIDPageRouteImport } from './pages/organization/UserDetailsByIDPage/route'
@@ -606,6 +605,13 @@ const adminAuthenticationPageRouteRoute =
getParentRoute: () => adminLayoutRoute,
} as any)
const adminAccessManagementPageRouteRoute =
adminAccessManagementPageRouteImport.update({
id: '/access-management',
path: '/access-management',
getParentRoute: () => adminLayoutRoute,
} as any)
const organizationProjectsPageRouteRoute =
organizationProjectsPageRouteImport.update({
id: '/projects',
@@ -693,24 +699,10 @@ const AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdRoute
} as any,
)
const adminUserIdentitiesResourcesPageRouteRoute =
adminUserIdentitiesResourcesPageRouteImport.update({
id: '/resources/user-identities',
path: '/resources/user-identities',
getParentRoute: () => adminLayoutRoute,
} as any)
const adminOrganizationResourcesPageRouteRoute =
adminOrganizationResourcesPageRouteImport.update({
id: '/resources/organizations',
path: '/resources/organizations',
getParentRoute: () => adminLayoutRoute,
} as any)
const adminMachineIdentitiesResourcesPageRouteRoute =
adminMachineIdentitiesResourcesPageRouteImport.update({
id: '/resources/machine-identities',
path: '/resources/machine-identities',
const adminResourceOverviewPageRouteRoute =
adminResourceOverviewPageRouteImport.update({
id: '/resources/overview',
path: '/resources/overview',
getParentRoute: () => adminLayoutRoute,
} as any)
@@ -2304,6 +2296,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof organizationProjectsPageRouteImport
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport
}
'/_authenticate/_inject-org-details/admin/_admin-layout/access-management': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/access-management'
path: '/access-management'
fullPath: '/admin/access-management'
preLoaderRoute: typeof adminAccessManagementPageRouteImport
parentRoute: typeof adminLayoutImport
}
'/_authenticate/_inject-org-details/admin/_admin-layout/authentication': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/authentication'
path: '/authentication'
@@ -2437,25 +2436,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof organizationSecretSharingSettingsPageRouteImport
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingImport
}
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities'
path: '/resources/machine-identities'
fullPath: '/admin/resources/machine-identities'
preLoaderRoute: typeof adminMachineIdentitiesResourcesPageRouteImport
parentRoute: typeof adminLayoutImport
}
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations'
path: '/resources/organizations'
fullPath: '/admin/resources/organizations'
preLoaderRoute: typeof adminOrganizationResourcesPageRouteImport
parentRoute: typeof adminLayoutImport
}
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities'
path: '/resources/user-identities'
fullPath: '/admin/resources/user-identities'
preLoaderRoute: typeof adminUserIdentitiesResourcesPageRouteImport
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': {
id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview'
path: '/resources/overview'
fullPath: '/admin/resources/overview'
preLoaderRoute: typeof adminResourceOverviewPageRouteImport
parentRoute: typeof adminLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId': {
@@ -4438,29 +4423,24 @@ const organizationLayoutRouteWithChildren =
interface adminLayoutRouteChildren {
adminGeneralPageRouteRoute: typeof adminGeneralPageRouteRoute
adminAccessManagementPageRouteRoute: typeof adminAccessManagementPageRouteRoute
adminAuthenticationPageRouteRoute: typeof adminAuthenticationPageRouteRoute
adminCachingPageRouteRoute: typeof adminCachingPageRouteRoute
adminEncryptionPageRouteRoute: typeof adminEncryptionPageRouteRoute
adminEnvironmentPageRouteRoute: typeof adminEnvironmentPageRouteRoute
adminIntegrationsPageRouteRoute: typeof adminIntegrationsPageRouteRoute
adminMachineIdentitiesResourcesPageRouteRoute: typeof adminMachineIdentitiesResourcesPageRouteRoute
adminOrganizationResourcesPageRouteRoute: typeof adminOrganizationResourcesPageRouteRoute
adminUserIdentitiesResourcesPageRouteRoute: typeof adminUserIdentitiesResourcesPageRouteRoute
adminResourceOverviewPageRouteRoute: typeof adminResourceOverviewPageRouteRoute
}
const adminLayoutRouteChildren: adminLayoutRouteChildren = {
adminGeneralPageRouteRoute: adminGeneralPageRouteRoute,
adminAccessManagementPageRouteRoute: adminAccessManagementPageRouteRoute,
adminAuthenticationPageRouteRoute: adminAuthenticationPageRouteRoute,
adminCachingPageRouteRoute: adminCachingPageRouteRoute,
adminEncryptionPageRouteRoute: adminEncryptionPageRouteRoute,
adminEnvironmentPageRouteRoute: adminEnvironmentPageRouteRoute,
adminIntegrationsPageRouteRoute: adminIntegrationsPageRouteRoute,
adminMachineIdentitiesResourcesPageRouteRoute:
adminMachineIdentitiesResourcesPageRouteRoute,
adminOrganizationResourcesPageRouteRoute:
adminOrganizationResourcesPageRouteRoute,
adminUserIdentitiesResourcesPageRouteRoute:
adminUserIdentitiesResourcesPageRouteRoute,
adminResourceOverviewPageRouteRoute: adminResourceOverviewPageRouteRoute,
}
const adminLayoutRouteWithChildren = adminLayoutRoute._addFileChildren(
@@ -4652,6 +4632,7 @@ export interface FileRoutesByFullPath {
'/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
'/organization/billing': typeof organizationBillingPageRouteRoute
'/organization/projects': typeof organizationProjectsPageRouteRoute
'/admin/access-management': typeof adminAccessManagementPageRouteRoute
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
'/admin/caching': typeof adminCachingPageRouteRoute
'/admin/encryption': typeof adminEncryptionPageRouteRoute
@@ -4671,9 +4652,7 @@ export interface FileRoutesByFullPath {
'/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
'/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
'/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
'/admin/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute
'/admin/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute
'/admin/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute
'/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute
'/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren
'/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren
'/projects/secret-management/$projectId': typeof secretManagerLayoutRouteWithChildren
@@ -4870,6 +4849,7 @@ export interface FileRoutesByTo {
'/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
'/organization/billing': typeof organizationBillingPageRouteRoute
'/organization/projects': typeof organizationProjectsPageRouteRoute
'/admin/access-management': typeof adminAccessManagementPageRouteRoute
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
'/admin/caching': typeof adminCachingPageRouteRoute
'/admin/encryption': typeof adminEncryptionPageRouteRoute
@@ -4885,9 +4865,7 @@ export interface FileRoutesByTo {
'/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
'/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
'/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
'/admin/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute
'/admin/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute
'/admin/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute
'/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute
'/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren
'/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren
'/projects/secret-management/$projectId': typeof secretManagerLayoutRouteWithChildren
@@ -5088,6 +5066,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organization/billing': typeof organizationBillingPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organization/projects': typeof organizationProjectsPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/access-management': typeof adminAccessManagementPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/authentication': typeof adminAuthenticationPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/caching': typeof adminCachingPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/encryption': typeof adminEncryptionPageRouteRoute
@@ -5107,9 +5086,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute
'/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': typeof adminResourceOverviewPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdRouteWithChildren
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsKmsProjectIdRouteWithChildren
'/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsSecretManagementProjectIdRouteWithChildren
@@ -5317,6 +5294,7 @@ export interface FileRouteTypes {
| '/organization/audit-logs'
| '/organization/billing'
| '/organization/projects'
| '/admin/access-management'
| '/admin/authentication'
| '/admin/caching'
| '/admin/encryption'
@@ -5336,9 +5314,7 @@ export interface FileRouteTypes {
| '/organization/members/$membershipId'
| '/organization/roles/$roleId'
| '/organization/secret-sharing/settings'
| '/admin/resources/machine-identities'
| '/admin/resources/organizations'
| '/admin/resources/user-identities'
| '/admin/resources/overview'
| '/projects/cert-management/$projectId'
| '/projects/kms/$projectId'
| '/projects/secret-management/$projectId'
@@ -5534,6 +5510,7 @@ export interface FileRouteTypes {
| '/organization/audit-logs'
| '/organization/billing'
| '/organization/projects'
| '/admin/access-management'
| '/admin/authentication'
| '/admin/caching'
| '/admin/encryption'
@@ -5549,9 +5526,7 @@ export interface FileRouteTypes {
| '/organization/members/$membershipId'
| '/organization/roles/$roleId'
| '/organization/secret-sharing/settings'
| '/admin/resources/machine-identities'
| '/admin/resources/organizations'
| '/admin/resources/user-identities'
| '/admin/resources/overview'
| '/projects/cert-management/$projectId'
| '/projects/kms/$projectId'
| '/projects/secret-management/$projectId'
@@ -5750,6 +5725,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/organization/audit-logs'
| '/_authenticate/_inject-org-details/_org-layout/organization/billing'
| '/_authenticate/_inject-org-details/_org-layout/organization/projects'
| '/_authenticate/_inject-org-details/admin/_admin-layout/access-management'
| '/_authenticate/_inject-org-details/admin/_admin-layout/authentication'
| '/_authenticate/_inject-org-details/admin/_admin-layout/caching'
| '/_authenticate/_inject-org-details/admin/_admin-layout/encryption'
@@ -5769,9 +5745,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId'
| '/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId'
| '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings'
| '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities'
| '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations'
| '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities'
| '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId'
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId'
| '/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId'
@@ -6195,14 +6169,13 @@ export const routeTree = rootRoute
"parent": "/_authenticate/_inject-org-details/admin",
"children": [
"/_authenticate/_inject-org-details/admin/_admin-layout/",
"/_authenticate/_inject-org-details/admin/_admin-layout/access-management",
"/_authenticate/_inject-org-details/admin/_admin-layout/authentication",
"/_authenticate/_inject-org-details/admin/_admin-layout/caching",
"/_authenticate/_inject-org-details/admin/_admin-layout/encryption",
"/_authenticate/_inject-org-details/admin/_admin-layout/environment",
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations",
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities",
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations",
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities"
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview"
]
},
"/_authenticate/_inject-org-details/admin/_admin-layout/": {
@@ -6225,6 +6198,10 @@ export const routeTree = rootRoute
"filePath": "organization/ProjectsPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/organization"
},
"/_authenticate/_inject-org-details/admin/_admin-layout/access-management": {
"filePath": "admin/AccessManagementPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
},
"/_authenticate/_inject-org-details/admin/_admin-layout/authentication": {
"filePath": "admin/AuthenticationPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
@@ -6319,16 +6296,8 @@ export const routeTree = rootRoute
"filePath": "organization/SecretSharingSettingsPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing"
},
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities": {
"filePath": "admin/MachineIdentitiesResourcesPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
},
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations": {
"filePath": "admin/OrganizationResourcesPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
},
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities": {
"filePath": "admin/UserIdentitiesResourcesPage/route.tsx",
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview": {
"filePath": "admin/ResourceOverviewPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
},
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId": {
+2 -3
View File
@@ -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")
])
]);