Merge remote-tracking branch 'origin/main' into misc/terraform-project-group-prereq

This commit is contained in:
Sheen Capadngan
2024-09-23 15:09:56 +08:00
166 changed files with 7249 additions and 1987 deletions
+19 -11
View File
@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "relock-npm-lock-v2-SvMQeF",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -65,7 +65,7 @@
"i18next-http-backend": "^2.2.0",
"infisical-node": "^1.0.37",
"ip": "^2.0.1",
"jspdf": "^2.5.1",
"jspdf": "^2.5.2",
"jsrp": "^0.2.4",
"jwt-decode": "^3.1.2",
"lottie-react": "^2.4.0",
@@ -12729,9 +12729,10 @@
}
},
"node_modules/dompurify": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz",
"integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.6.tgz",
"integrity": "sha512-zUTaUBO8pY4+iJMPE1B9XlO2tXVYIcEA4SNGtvDELzTSCQO7RzH+j7S180BmhmJId78lqGU2z19vgVx2Sxs/PQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true
},
"node_modules/domutils": {
@@ -16873,22 +16874,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/jspdf": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.1.tgz",
"integrity": "sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.14.0",
"@babel/runtime": "^7.23.2",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.4.8"
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.2.0",
"dompurify": "^2.5.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf/node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
+1 -1
View File
@@ -73,7 +73,7 @@
"i18next-http-backend": "^2.2.0",
"infisical-node": "^1.0.37",
"ip": "^2.0.1",
"jspdf": "^2.5.1",
"jspdf": "^2.5.2",
"jsrp": "^0.2.4",
"jwt-decode": "^3.1.2",
"lottie-react": "^2.4.0",
@@ -0,0 +1,36 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
// To give users example of possible values of TTL
export const FormLabelToolTip = ({ label, linkToMore, content }: { label: string, linkToMore: string, content: string }) => (
<div>
<FormLabel
label={label}
icon={
<Tooltip
content={
<span>
{content}{" "}
<a
href={linkToMore}
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
/>
</Tooltip>
}
/>
</div>
);
@@ -1,36 +1,12 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
import { FormLabelToolTip } from "./FormLabelToolTip";
// To give users example of possible values of TTL
export const TtlFormLabel = ({ label }: { label: string }) => (
<div>
<FormLabel
<FormLabelToolTip
label={label}
icon={
<Tooltip
content={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
/>
</Tooltip>
}
content="1m, 2h, 3d. "
linkToMore="https://github.com/vercel/ms?tab=readme-ov-file#examples"
/>
</div>
);
@@ -100,7 +100,7 @@ export default function NavHeader({
onValueChange={(value) => {
if (value && onEnvChange) onEnvChange(value);
}}
className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
>
{userAvailableEnvs?.map(({ name, slug }) => (
@@ -1,3 +1,4 @@
import { ReactElement } from "react";
import {
faCaretDown,
faCheck,
@@ -23,6 +24,7 @@ export type PaginationProps = {
onChangePerPage: (newRows: number) => void;
className?: string;
perPageList?: number[];
startAdornment?: ReactElement;
};
export const Pagination = ({
@@ -32,7 +34,8 @@ export const Pagination = ({
onChangePage,
onChangePerPage,
perPageList = [10, 20, 50, 100],
className
className,
startAdornment
}: PaginationProps) => {
const prevPageNumber = Math.max(1, page - 1);
const canGoPrev = page > 1;
@@ -46,11 +49,12 @@ export const Pagination = ({
return (
<div
className={twMerge(
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
className
)}
>
<div className="mr-6 flex items-center space-x-2">
{startAdornment}
<div className="ml-auto mr-6 flex items-center space-x-2">
<div className="text-xs">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div>
@@ -21,7 +21,8 @@ export enum OrgPermissionSubjects {
SecretScanning = "secret-scanning",
Identity = "identity",
Kms = "kms",
AdminConsole = "organization-admin-console"
AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs"
}
export enum OrgPermissionAdminConsoleAction {
@@ -43,6 +44,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs];
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
@@ -25,6 +25,21 @@ export const UserProvider = ({ children }: Props): JSX.Element => {
};
}, [data, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
<img
src="/images/loading/loading.gif"
height={70}
width={120}
decoding="async"
loading="lazy"
alt="infisical loading indicator"
/>
</div>
);
}
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
};
@@ -1,6 +1,7 @@
import { createContext, ReactNode, useContext, useMemo } from "react";
import { createContext, ReactNode, useContext, useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { createNotification } from "@app/components/notifications";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { Workspace } from "@app/hooks/api/workspace/types";
@@ -31,6 +32,34 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => {
};
}, [ws, workspaceId, isLoading]);
const shouldTriggerNoProjectAccess =
!value.isLoading &&
!value.currentWorkspace &&
router.pathname.startsWith("/project") &&
workspaceId;
// handle redirects for project-specific routes
useEffect(() => {
if (shouldTriggerNoProjectAccess) {
createNotification({
text: "You are not a member of this project.",
type: "info"
});
setTimeout(() => {
router.push("/");
}, 5000);
}
}, [shouldTriggerNoProjectAccess, router]);
if (shouldTriggerNoProjectAccess) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800 text-primary-50">
You do not have sufficient access to this project.
</div>
);
}
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
};
@@ -79,7 +79,8 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]:
"Update certificate template EST configuration",
[EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration",
[EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration"
[EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration",
[EventType.INTEGRATION_SYNCED]: "Integration sync"
};
export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = {
+3 -1
View File
@@ -1,4 +1,5 @@
export enum ActorType {
PLATFORM = "platform",
USER = "user",
SERVICE = "service",
IDENTITY = "identity"
@@ -91,5 +92,6 @@ export enum EventType {
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config"
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
INTEGRATION_SYNCED = "integration-synced"
}
+39 -16
View File
@@ -1,35 +1,58 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, UseInfiniteQueryOptions, useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { Actor, AuditLog, AuditLogFilters } from "./types";
import { Actor, AuditLog, TGetAuditLogsFilter } from "./types";
export const auditLogKeys = {
getAuditLogs: (workspaceId: string | null, filters: AuditLogFilters) =>
getAuditLogs: (workspaceId: string | null, filters: TGetAuditLogsFilter) =>
[{ workspaceId, filters }, "audit-logs"] as const,
getAuditLogActorFilterOpts: (workspaceId: string) =>
[{ workspaceId }, "audit-log-actor-filters"] as const
};
export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => {
export const useGetAuditLogs = (
filters: TGetAuditLogsFilter,
projectId: string | null,
options: Omit<
UseInfiniteQueryOptions<
AuditLog[],
unknown,
AuditLog[],
AuditLog[],
ReturnType<typeof auditLogKeys.getAuditLogs>
>,
"queryFn" | "queryKey" | "getNextPageParam"
> = {}
) => {
return useInfiniteQuery({
queryKey: auditLogKeys.getAuditLogs(workspaceId, filters),
queryKey: auditLogKeys.getAuditLogs(projectId, filters),
queryFn: async ({ pageParam }) => {
const auditLogEndpoint = workspaceId
? `/api/v1/workspace/${workspaceId}/audit-logs`
: "/api/v1/organization/audit-logs";
const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>(auditLogEndpoint, {
params: {
...filters,
offset: pageParam,
startDate: filters?.startDate?.toISOString(),
endDate: filters?.endDate?.toISOString()
const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>(
"/api/v1/organization/audit-logs",
{
params: {
...filters,
offset: pageParam,
startDate: filters?.startDate?.toISOString(),
endDate: filters?.endDate?.toISOString(),
...(filters.eventMetadata && Object.keys(filters.eventMetadata).length
? {
eventMetadata: Object.entries(filters.eventMetadata)
.map(([key, value]) => `${key}=${value}`)
.join(",")
}
: {}),
...(filters.eventType?.length ? { eventType: filters.eventType.join(",") } : {}),
...(projectId ? { projectId } : {})
}
}
});
);
return data.auditLogs;
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
...options
});
};
+38 -12
View File
@@ -3,6 +3,18 @@ import { IdentityTrustedIp } from "../identities/types";
import { PkiItemType } from "../pkiCollections/constants";
import { ActorType, EventType, UserAgentType } from "./enums";
export type TGetAuditLogsFilter = {
eventType?: EventType[];
userAgentType?: UserAgentType;
eventMetadata?: Record<string, string>;
actorType?: ActorType;
projectId?: string;
actorId?: string; // user ID format
startDate?: Date;
endDate?: Date;
limit: number;
};
interface UserActorMetadata {
userId: string;
email: string;
@@ -33,7 +45,13 @@ export interface IdentityActor {
metadata: IdentityActorMetadata;
}
export type Actor = UserActor | ServiceActor | IdentityActor;
export interface PlatformActorMetadata {}
export interface PlatformActor {
type: ActorType.PLATFORM;
metadata: PlatformActorMetadata;
}
export type Actor = UserActor | ServiceActor | IdentityActor | PlatformActor;
interface GetSecretsEvent {
type: EventType.GET_SECRETS;
@@ -761,6 +779,22 @@ interface GetProjectSlackConfig {
};
}
export enum IntegrationSyncedEventTrigger {
MANUAL = "manual",
AUTO = "auto"
}
interface IntegrationSyncedEvent {
type: EventType.INTEGRATION_SYNCED;
metadata: {
integrationId: string;
lastSyncJobId: string;
lastUsed: Date;
syncMessage: string;
isSynced: boolean;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -838,7 +872,8 @@ export type Event =
| CreateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig
| UpdateProjectSlackConfig
| GetProjectSlackConfig;
| GetProjectSlackConfig
| IntegrationSyncedEvent;
export type AuditLog = {
id: string;
@@ -851,17 +886,8 @@ export type AuditLog = {
userAgentType: UserAgentType;
createdAt: string;
updatedAt: string;
project: {
project?: {
name: string;
slug: string;
};
};
export type AuditLogFilters = {
eventType?: EventType;
userAgentType?: UserAgentType;
actor?: string;
limit: number;
startDate?: Date;
endDate?: Date;
};
@@ -0,0 +1 @@
export { useGetProjectSecretsDetails } from "./queries";
@@ -0,0 +1,261 @@
import { useCallback } from "react";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
import { apiRequest } from "@app/config/request";
import {
DashboardProjectSecretsDetails,
DashboardProjectSecretsDetailsResponse,
DashboardProjectSecretsOverview,
DashboardProjectSecretsOverviewResponse,
DashboardSecretsOrderBy,
TGetDashboardProjectSecretsDetailsDTO,
TGetDashboardProjectSecretsOverviewDTO
} from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
export const dashboardKeys = {
all: () => ["dashboard"] as const,
getDashboardSecrets: ({
projectId,
secretPath
}: Pick<TGetDashboardProjectSecretsDetailsDTO, "projectId" | "secretPath">) =>
[...dashboardKeys.all(), { projectId, secretPath }] as const,
getProjectSecretsOverview: ({
projectId,
secretPath,
...params
}: TGetDashboardProjectSecretsOverviewDTO) =>
[
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
"secrets-overview",
params
] as const,
getProjectSecretsDetails: ({
projectId,
secretPath,
environment,
...params
}: TGetDashboardProjectSecretsDetailsDTO) =>
[
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
environment,
"secrets-details",
params
] as const
};
export const fetchProjectSecretsOverview = async ({
includeFolders,
includeSecrets,
includeDynamicSecrets,
environments,
...params
}: TGetDashboardProjectSecretsOverviewDTO) => {
const { data } = await apiRequest.get<DashboardProjectSecretsOverviewResponse>(
"/api/v3/dashboard/secrets-overview",
{
params: {
...params,
environments: encodeURIComponent(environments.join(",")),
includeFolders: includeFolders ? "1" : "",
includeSecrets: includeSecrets ? "1" : "",
includeDynamicSecrets: includeDynamicSecrets ? "1" : ""
}
}
);
return data;
};
export const fetchProjectSecretsDetails = async ({
includeFolders,
includeImports,
includeSecrets,
includeDynamicSecrets,
tags,
...params
}: TGetDashboardProjectSecretsDetailsDTO) => {
const { data } = await apiRequest.get<DashboardProjectSecretsDetailsResponse>(
"/api/v3/dashboard/secrets-details",
{
params: {
...params,
includeImports: includeImports ? "1" : "",
includeFolders: includeFolders ? "1" : "",
includeSecrets: includeSecrets ? "1" : "",
includeDynamicSecrets: includeDynamicSecrets ? "1" : "",
tags: encodeURIComponent(
Object.entries(tags)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, enabled]) => enabled)
.map(([tag]) => tag)
.join(",")
)
}
}
);
return data;
};
export const useGetProjectSecretsOverview = (
{
projectId,
secretPath,
offset = 0,
limit = 100,
orderBy = DashboardSecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = "",
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}: TGetDashboardProjectSecretsOverviewDTO,
options?: Omit<
UseQueryOptions<
DashboardProjectSecretsOverviewResponse,
unknown,
DashboardProjectSecretsOverview,
ReturnType<typeof dashboardKeys.getProjectSecretsOverview>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
...options,
// wait for all values to be available
enabled: Boolean(projectId) && (options?.enabled ?? true) && Boolean(environments.length),
queryKey: dashboardKeys.getProjectSecretsOverview({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}),
queryFn: () =>
fetchProjectSecretsOverview({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
});
}
},
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
const { secrets, ...select } = data;
return {
...select,
secrets: secrets ? mergePersonalSecrets(secrets) : undefined
};
}, []),
keepPreviousData: true
});
};
export const useGetProjectSecretsDetails = (
{
projectId,
secretPath,
environment,
offset = 0,
limit = 100,
orderBy = DashboardSecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = "",
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}: TGetDashboardProjectSecretsDetailsDTO,
options?: Omit<
UseQueryOptions<
DashboardProjectSecretsDetailsResponse,
unknown,
DashboardProjectSecretsDetails,
ReturnType<typeof dashboardKeys.getProjectSecretsDetails>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
...options,
// wait for all values to be available
enabled: Boolean(projectId) && (options?.enabled ?? true),
queryKey: dashboardKeys.getProjectSecretsDetails({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
environment,
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}),
queryFn: () =>
fetchProjectSecretsDetails({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
environment,
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
});
}
},
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
...data,
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
}),
[]
),
keepPreviousData: true
});
};
+68
View File
@@ -0,0 +1,68 @@
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export type DashboardProjectSecretsOverviewResponse = {
folders?: (TSecretFolder & { environment: string })[];
dynamicSecrets?: (TDynamicSecret & { environment: string })[];
secrets?: SecretV3Raw[];
totalSecretCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalCount: number;
};
export type DashboardProjectSecretsDetailsResponse = {
imports?: TSecretImport[];
folders?: TSecretFolder[];
dynamicSecrets?: TDynamicSecret[];
secrets?: SecretV3Raw[];
totalImportCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalSecretCount?: number;
totalCount: number;
};
export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
};
export type DashboardProjectSecretsDetails = Omit<
DashboardProjectSecretsDetailsResponse,
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
};
export enum DashboardSecretsOrderBy {
Name = "name"
}
export type TGetDashboardProjectSecretsOverviewDTO = {
projectId: string;
secretPath: string;
offset?: number;
limit?: number;
orderBy?: DashboardSecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
includeSecrets?: boolean;
includeFolders?: boolean;
includeDynamicSecrets?: boolean;
environments: string[];
};
export type TGetDashboardProjectSecretsDetailsDTO = Omit<
TGetDashboardProjectSecretsOverviewDTO,
"environments"
> & {
environment: string;
includeImports?: boolean;
tags: Record<string, boolean>;
};
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { dynamicSecretKeys } from "./queries";
import {
@@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but we currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});
@@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});
@@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});
@@ -71,6 +71,34 @@ export const useGetDynamicSecretDetails = ({
});
};
export const useGetDynamicSecretProviderData = ({
tenantId,
applicationId,
clientSecret,
enabled
}: {
tenantId: string;
applicationId: string;
clientSecret: string;
enabled: boolean
}) => {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await apiRequest.post<{id:string, email: string, name:string}[]>(
"/api/v1/dynamic-secrets/entra-id/users",
{
tenantId,
applicationId,
clientSecret
}
);
return data;
},
enabled
});
};
export const useGetDynamicSecretsOfAllEnv = ({
path,
projectSlug,
+13 -2
View File
@@ -24,7 +24,8 @@ export enum DynamicSecretProviders {
MongoAtlas = "mongo-db-atlas",
ElasticSearch = "elastic-search",
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq"
RabbitMq = "rabbit-mq",
AzureEntraId = "azure-entra-id"
}
export enum SqlProviders {
@@ -177,7 +178,17 @@ export type TDynamicSecretProvider =
};
ca?: string;
};
};
}
| {
type: DynamicSecretProviders.AzureEntraId;
inputs: {
tenantId: string;
userId: string;
email: string;
applicationId: string;
clientSecret: string;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
@@ -1 +1,6 @@
export { useCreateIntegration, useDeleteIntegration, useGetCloudIntegrations } from "./queries";
export {
useCreateIntegration,
useDeleteIntegration,
useGetCloudIntegrations,
useGetIntegration
} from "./queries";
@@ -1,13 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
import { createNotification } from "@app/components/notifications";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace";
import { TCloudIntegration } from "./types";
import { TCloudIntegration, TIntegrationWithEnv } from "./types";
export const integrationQueryKeys = {
getIntegrations: () => ["integrations"] as const
getIntegrations: () => ["integrations"] as const,
getIntegration: (id: string) => ["integration", id] as const
};
const fetchIntegrations = async () => {
@@ -18,6 +19,14 @@ const fetchIntegrations = async () => {
return data.integrationOptions;
};
const fetchIntegration = async (id: string) => {
const { data } = await apiRequest.get<{ integration: TIntegrationWithEnv }>(
`/api/v1/integration/${id}`
);
return data.integration;
};
export const useGetCloudIntegrations = () =>
useQuery({
queryKey: integrationQueryKeys.getIntegrations(),
@@ -128,6 +137,26 @@ export const useDeleteIntegration = () => {
});
};
export const useGetIntegration = (
integrationId: string,
options?: Omit<
UseQueryOptions<
TIntegrationWithEnv,
unknown,
TIntegrationWithEnv,
ReturnType<typeof integrationQueryKeys.getIntegration>
>,
"queryFn" | "queryKey"
>
) => {
return useQuery({
...options,
enabled: Boolean(integrationId && options?.enabled === undefined ? true : options?.enabled),
queryKey: integrationQueryKeys.getIntegration(integrationId),
queryFn: () => fetchIntegration(integrationId)
});
};
export const useSyncIntegration = () => {
return useMutation<{}, {}, { id: string; workspaceId: string; lastUsed: string }>({
mutationFn: ({ id }) => apiRequest.post(`/api/v1/integration/${id}/sync`),
@@ -36,14 +36,34 @@ export type TIntegration = {
metadata?: {
githubVisibility?: string;
githubVisibilityRepoIds?: string[];
shouldAutoRedeploy?: boolean;
secretAWSTag?: {
key: string;
value: string;
}[];
kmsKeyId?: string;
secretSuffix?: string;
secretPrefix?: string;
syncBehavior?: IntegrationSyncBehavior;
mappingBehavior?: IntegrationMappingBehavior;
scope: string;
org: string;
project: string;
environment: string;
shouldDisableDelete?: boolean;
shouldMaskSecrets?: boolean;
shouldProtectSecrets?: boolean;
shouldEnableDelete?: boolean;
};
};
export type TIntegrationWithEnv = TIntegration & {
environment: {
id: string;
name: string;
slug: string;
};
};
@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries";
import {
@@ -124,6 +125,12 @@ export const useCreateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -151,6 +158,12 @@ export const useUpdateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -179,6 +192,12 @@ export const useDeleteFolder = () => {
return data;
},
onSuccess: (_, { path = "/", projectId, environment }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => {
},
onSuccess: (_, { projectId, folders }) => {
folders.forEach((folder) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: folder.path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({
projectId,
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretImportKeys } from "./queries";
import {
@@ -31,6 +32,9 @@ export const useCreateSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};
@@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ environment, path, projectId })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};
@@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};
@@ -1,6 +1,7 @@
import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries";
@@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -295,6 +314,12 @@ export const useMoveSecrets = ({
return data;
},
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: sourceSecretPath
})
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({
workspaceId: projectId,
+1
View File
@@ -0,0 +1 @@
export * from "./secrets-overview";
@@ -0,0 +1,86 @@
import { useCallback, useMemo } from "react";
import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types";
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
const folderNames = useMemo(() => {
const names = new Set<string>();
folders?.forEach((folder) => {
names.add(folder.name);
});
return [...names];
}, [folders]);
const isFolderPresentInEnv = useCallback(
(name: string, env: string) => {
return Boolean(
folders?.find(
({ name: folderName, environment }) => folderName === name && environment === env
)
);
},
[folders]
);
const getFolderByNameAndEnv = useCallback(
(name: string, env: string) => {
return folders?.find(
({ name: folderName, environment }) => folderName === name && environment === env
);
},
[folders]
);
return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
};
export const useDynamicSecretOverview = (
dynamicSecrets: DashboardProjectSecretsOverview["dynamicSecrets"]
) => {
const dynamicSecretNames = useMemo(() => {
const names = new Set<string>();
dynamicSecrets?.forEach((dynamicSecret) => {
names.add(dynamicSecret.name);
});
return [...names];
}, [dynamicSecrets]);
const isDynamicSecretPresentInEnv = useCallback(
(name: string, env: string) => {
return Boolean(
dynamicSecrets?.find(
({ name: dynamicSecretName, environment }) =>
dynamicSecretName === name && environment === env
)
);
},
[dynamicSecrets]
);
return { dynamicSecretNames, isDynamicSecretPresentInEnv };
};
export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => {
const secKeys = useMemo(() => {
const keys = new Set<string>();
secrets?.forEach((secret) => keys.add(secret.key));
return [...keys];
}, [secrets]);
const getEnvSecretKeyCount = useCallback(
(env: string) => {
return secrets?.filter((secret) => secret.env === env).length ?? 0;
},
[secrets]
);
const getSecretByKey = useCallback(
(env: string, key: string) => {
const sec = secrets?.find((s) => s.env === env && s.key === key);
return sec;
},
[secrets]
);
return { secKeys, getSecretByKey, getEnvSecretKeyCount };
};
+10 -12
View File
@@ -675,18 +675,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?.id}/audit-logs`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?.id}/audit-logs`
}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?.id}/settings`} passHref>
<a>
<MenuItem
@@ -755,6 +743,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
</a>
</Link>
)}
<Link href={`/org/${currentOrg?.id}/audit-logs`} passHref>
<a>
<MenuItem
isSelected={router.asPath === `/org/${currentOrg?.id}/audit-logs`}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
<Link href={`/org/${currentOrg?.id}/settings`} passHref>
<a>
<MenuItem
@@ -0,0 +1,117 @@
import React, { ErrorInfo, ReactNode, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { faBugs, faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
const ErrorPage = ({ error }: { error: Error | null }) => {
const [orgId, setOrgId] = React.useState<string | null>(null);
const router = useRouter();
const currentUrl = router?.asPath?.split("?")?.[0];
// Workaround: Fixes localStorage not being available in the error boundary until the next render.
useEffect(() => {
const savedOrgId = localStorage.getItem("orgData.id");
if (savedOrgId) {
setOrgId(savedOrgId);
}
}, []);
return (
<div className="flex h-screen w-screen items-center justify-center bg-mineshaft-900">
<div className="flex max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-8 text-center text-mineshaft-200">
<FontAwesomeIcon icon={faBugs} className="my-2 inline text-6xl" />
<p>
Something went wrong. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:[email protected]"
>
support@infisical.com
</a>
, or{" "}
<Link passHref href="https://infisical.com/slack">
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
>
join our Slack community
</a>
</Link>{" "}
if the issue persists.
</p>
{orgId && (
<Button
className="mt-4"
size="xs"
onClick={() =>
// we need to go to /org/${orgId}/overview, but we need to do a full page reload to ensure that the error the user is facing is properly reset.
window.location.assign(`/org/${orgId}/overview`)
}
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
Back To Home
</Button>
)}
{error?.message && (
<>
<div className="my-4 h-px w-full bg-mineshaft-600" />
<p className="thin-scrollbar max-h-44 w-full overflow-auto text-ellipsis rounded-md bg-mineshaft-700 p-2">
<code className="text-xs">
{currentUrl}, {error.message}
</code>
</p>
</>
)}
</div>
</div>
);
};
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("Error caught by ErrorBoundary:", error, errorInfo);
}
render(): ReactNode {
const { hasError, error } = this.state;
const { children } = this.props;
if (hasError) {
return <ErrorPage error={error} />;
}
return children;
}
}
const ErrorBoundaryWrapper = ({ children }: ErrorBoundaryProps) => {
return <ErrorBoundary>{children}</ErrorBoundary>;
};
export default ErrorBoundaryWrapper;
+39 -34
View File
@@ -27,6 +27,7 @@ import {
WorkspaceProvider
} from "@app/context";
import { AppLayout } from "@app/layouts";
import ErrorBoundaryWrapper from "@app/layouts/AppLayout/ErrorBoundary";
import { queryClient } from "@app/reactQuery";
import "nprogress/nprogress.css";
@@ -85,46 +86,50 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
!Component.requireAuth
) {
return (
<QueryClientProvider client={queryClient}>
<NotificationContainer />
<ServerConfigProvider>
<UserProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</UserProvider>
</ServerConfigProvider>
</QueryClientProvider>
<ErrorBoundaryWrapper>
<QueryClientProvider client={queryClient}>
<NotificationContainer />
<ServerConfigProvider>
<UserProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</UserProvider>
</ServerConfigProvider>
</QueryClientProvider>
</ErrorBoundaryWrapper>
);
}
const Layout = Component?.layout || AppLayout;
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<NotificationContainer />
<ServerConfigProvider>
<AuthProvider>
<OrgProvider>
<OrgPermissionProvider>
<WorkspaceProvider>
<ProjectPermissionProvider>
<SubscriptionProvider>
<UserProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</UserProvider>
</SubscriptionProvider>
</ProjectPermissionProvider>
</WorkspaceProvider>
</OrgPermissionProvider>
</OrgProvider>
</AuthProvider>
</ServerConfigProvider>
</TooltipProvider>
</QueryClientProvider>
<ErrorBoundaryWrapper>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<NotificationContainer />
<ServerConfigProvider>
<AuthProvider>
<OrgProvider>
<OrgPermissionProvider>
<WorkspaceProvider>
<ProjectPermissionProvider>
<SubscriptionProvider>
<UserProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</UserProvider>
</SubscriptionProvider>
</ProjectPermissionProvider>
</WorkspaceProvider>
</OrgPermissionProvider>
</OrgProvider>
</AuthProvider>
</ServerConfigProvider>
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundaryWrapper>
);
};
@@ -104,6 +104,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
const [tagKey, setTagKey] = useState("");
const [tagValue, setTagValue] = useState("");
const [kmsKeyId, setKmsKeyId] = useState("");
const [secretPrefix, setSecretPrefix] = useState("");
// const [path, setPath] = useState('');
// const [pathErrorText, setPathErrorText] = useState('');
@@ -165,6 +166,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
]
}
: {}),
...(secretPrefix && { secretPrefix }),
...(kmsKeyId && { kmsKeyId }),
mappingBehavior: selectedMappingBehavior
}
@@ -325,7 +327,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
</Switch>
</div>
{shouldTag && (
<div className="mt-4">
<div className="mt-4 flex justify-between">
<FormControl label="Tag Key">
<Input
placeholder="managed-by"
@@ -342,10 +344,20 @@ export default function AWSSecretManagerCreateIntegrationPage() {
</FormControl>
</div>
)}
<FormControl label="Secret Prefix" className="mt-4">
<Input
value={secretPrefix}
onChange={(e) => setSecretPrefix(e.target.value)}
placeholder="INFISICAL_"
/>
</FormControl>
<FormControl label="Encryption Key" className="mt-4">
<Select
value={kmsKeyId}
onValueChange={(e) => {
if (e === "no-keys") return;
setKmsKeyId(e);
}}
className="w-full border border-mineshaft-500"
@@ -363,7 +375,9 @@ export default function AWSSecretManagerCreateIntegrationPage() {
);
})
) : (
<div />
<SelectItem isDisabled value="no-keys" key="no-keys">
No KMS keys available
</SelectItem>
)}
</Select>
</FormControl>
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { IntegrationDetailsPage } from "@app/views/IntegrationsPage/IntegrationDetailsPage";
export default function IntegrationsDetailsPage() {
const { t } = useTranslation();
return (
<>
<Head>
<title>Integration Details | Infisical</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Manage your .env files in seconds" />
<meta name="og:description" content={t("integrations.description") as string} />
</Head>
<IntegrationDetailsPage />
</>
);
}
IntegrationsDetailsPage.requireAuth = true;
@@ -1,15 +1,12 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { AuditLogsPage } from "@app/views/Project/AuditLogsPage";
import { AuditLogsPage } from "@app/views/Org/AuditLogsPage";
const Logs = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<title>Infisical | Audit Logs</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
@@ -0,0 +1,120 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useUser,
useWorkspace
} from "@app/context";
import { useGetIntegration } from "@app/hooks/api";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { IntegrationAuditLogsSection } from "./components/IntegrationAuditLogsSection";
import { IntegrationConnectionSection } from "./components/IntegrationConnectionSection";
import { IntegrationDetailsSection } from "./components/IntegrationDetailsSection";
import { IntegrationSettingsSection } from "./components/IntegrationSettingsSection";
export const IntegrationDetailsPage = () => {
const router = useRouter();
const integrationId = router.query.integrationId as string;
const { data: integration } = useGetIntegration(integrationId, {
refetchInterval: 4000
});
const projectId = useWorkspace().currentWorkspace?.id;
const { mutateAsync: syncIntegration } = useSyncIntegration();
const { currentOrg } = useOrganization();
return integration ? (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
router.push(`/integrations/${projectId}`);
}}
className="mb-4"
>
Integrations
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{integrationSlugNameMapping[integration.integration]} Integration
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={async () => {
await syncIntegration({
id: integration.id,
lastUsed: integration.lastUsed!,
workspaceId: projectId!
});
}}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRefresh} />
Manually Sync
</div>
</DropdownMenuItem>
<OrgPermissionCan I={OrgPermissionActions.Delete} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {}}
disabled={!isAllowed}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTrash} />
Delete Integration
</div>
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-center">
<div className="mr-4 w-96">
<IntegrationDetailsSection integration={integration} />
<IntegrationConnectionSection integration={integration} />
</div>
<div className="space-y-4">
<IntegrationSettingsSection integration={integration} />
<IntegrationAuditLogsSection orgId={currentOrg?.id || ""} integration={integration} />
</div>
</div>
</div>
</div>
) : null;
};
@@ -0,0 +1,78 @@
import Link from "next/link";
import { EmptyState } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { LogsSection } from "@app/views/Org/AuditLogsPage/components";
// Add more events if needed
const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED];
type Props = {
integration: TIntegrationWithEnv;
orgId: string;
};
export const IntegrationAuditLogsSection = ({ integration, orgId }: Props) => {
const { subscription, isLoading } = useSubscription();
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
// eslint-disable-next-line no-nested-ternary
return subscription?.auditLogs ? (
<div className="h-full w-full min-w-[51rem] rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
<p className="text-xs text-gray-400">
Displaying audit logs from the last {auditLogsRetentionDays} days
</p>
</div>
<LogsSection
refetchInterval={4000}
remappedHeaders={{
Metadata: "Sync Status"
}}
showFilters={false}
presets={{
eventMetadata: { integrationId: integration.id },
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
eventType: INTEGRATION_EVENTS
}}
filterClassName="bg-mineshaft-900 static"
/>
</div>
) : !isLoading ? (
<div className="h-full w-full min-w-[51rem] rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4 opacity-60">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
</div>
<EmptyState
className="rounded-lg"
title={
<div>
<p>
Please{" "}
<Link
href={
subscription && subscription.slug !== null
? `/org${orgId}/billing`
: "https://infisical.com/scheduledemo"
}
passHref
>
<a
className="cursor-pointer font-medium text-primary-500 transition-all hover:text-primary-600"
target="_blank"
>
upgrade your subscription
</a>
</Link>{" "}
to view integration logs
</p>
</div>
}
/>
</div>
) : null;
};
@@ -0,0 +1,194 @@
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { FormLabel } from "@app/components/v2";
import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationConnectionSection = ({ integration }: Props) => {
const specifcQoveryDetails = () => {
if (integration.integration !== "qovery") return null;
return (
<div className="flex flex-row">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration?.owner || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Project" />
<div className="text-sm text-mineshaft-300">{integration?.targetService || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">{integration?.targetEnvironment || "-"}</div>
</div>
</div>
);
};
const isNotAwsManagerOneToOneDetails = () => {
const isAwsSecretManagerOneToOne =
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE;
if (isAwsSecretManagerOneToOne) {
return null;
}
const formLabel = () => {
switch (integration.integration) {
case "qovery":
return integration.scope;
case "circleci":
case "terraform-cloud":
return "Project";
case "aws-secret-manager":
return "Secret";
case "aws-parameter-store":
case "rundeck":
return "Path";
case "github":
if (["github-env", "github-repo"].includes(integration.scope!)) {
return "Repository";
}
return "Organization";
default:
return "App";
}
};
const contents = () => {
switch (integration.integration) {
case "hashicorp-vault":
return `${integration.app} - path: ${integration.path}`;
case "github":
if (integration.scope === "github-org") {
return `${integration.owner}`;
}
return `${integration.owner}/${integration.app}`;
case "aws-parameter-store":
case "rundeck":
return `${integration.path}`;
default:
return `${integration.app}`;
}
};
return (
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label={formLabel()} />
<div className="text-sm text-mineshaft-300">{contents()}</div>
</div>
);
};
const targetEnvironmentDetails = () => {
if (
["vercel", "netlify", "railway", "gitlab", "teamcity", "bitbucket"].includes(
integration.integration
) ||
(integration.integration === "github" && integration.scope === "github-env")
) {
return (
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
);
}
return null;
};
const generalIntegrationSpecificDetails = () => {
if (integration.integration === "checkly" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Group" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "circleci" && integration.owner) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration.owner}</div>
</div>
);
}
if (integration.integration === "terraform-cloud" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Category" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "checkly" || integration.integration === "github") {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Suffix" />
<div className="text-sm text-mineshaft-300">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
);
}
return null;
};
return (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Connection</h3>
</div>
<div className="mt-4">
<FormLabel className="my-2" label="Source" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Environment" />
<div className="text-sm text-mineshaft-300">{integration.environment.name}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Path" />
<div className="text-sm text-mineshaft-300">{integration.secretPath}</div>
</div>
</div>
<FormLabel className="my-2" label="Destination" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Platform" />
<div className="text-sm text-mineshaft-300">
{integrationSlugNameMapping[integration.integration]}
</div>
{specifcQoveryDetails()}
{isNotAwsManagerOneToOneDetails()}
{targetEnvironmentDetails()}
{generalIntegrationSpecificDetails()}
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import { faCalendarCheck, faCheckCircle, faCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { twMerge } from "tailwind-merge";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationDetailsSection = ({ integration }: Props) => {
return (
<div>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Integration Details</h3>
</div>
<div className="mt-4">
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{integrationSlugNameMapping[integration.integration]}
</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Sync Status</p>
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium",
integration.isSynced ? "text-green-500" : "text-red-500"
)}
>
{integration.isSynced ? "Synced" : "Not Synced"}
</p>
<FontAwesomeIcon
size="sm"
className={twMerge(integration.isSynced ? "text-green-500" : "text-red-500")}
icon={integration.isSynced ? faCheckCircle : faCircleXmark}
/>
</div>
</div>
{integration.lastUsed && (
<div>
<p className="text-sm font-semibold text-mineshaft-300">Latest Successful Sync</p>
<div className="flex items-center gap-2 text-sm text-mineshaft-300">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
</div>
</div>
)}
<div>
{!integration.isSynced && integration.syncMessage && (
<>
<p className="text-sm font-semibold text-mineshaft-300">Latest Sync Error</p>
<p className="text-sm text-mineshaft-300">{integration.syncMessage}</p>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegrationWithEnv;
};
type Metadata = NonNullable<TIntegrationWithEnv["metadata"]>;
type MetadataKey = keyof Metadata;
type MetadataValue<K extends MetadataKey> = Metadata[K];
const metadataMappings: Record<keyof NonNullable<TIntegrationWithEnv["metadata"]>, string> = {
githubVisibility: "Github Visibility",
githubVisibilityRepoIds: "Github Visibility Repo Ids",
shouldAutoRedeploy: "Auto Redeploy Target Application When Secrets Change",
secretAWSTag: "Tags For Secrets Stored In AWS",
kmsKeyId: "AWS KMS Key ID",
secretSuffix: "Secret Suffix",
secretPrefix: "Secret Prefix",
syncBehavior: "Secrets Sync behavior",
mappingBehavior: "Secrets Mapping Behavior",
scope: "Scope",
org: "Organization",
project: "Project",
environment: "Environment",
shouldDisableDelete: "AWS Secret Deletion Disabled",
shouldMaskSecrets: "GitLab Secrets Masking Enabled",
shouldProtectSecrets: "GitLab Secret Protection Enabled",
shouldEnableDelete: "GitHub Secret Deletion Enabled"
} as const;
export const IntegrationSettingsSection = ({ integration }: Props) => {
const renderValue = <K extends MetadataKey>(key: K, value: MetadataValue<K>) => {
if (!value) return null;
// If it's a boolean, we render a generic "Yes" or "No" response.
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
// When the value is an object or array, or array of objects, we need to handle some special cases.
if (typeof value === "object") {
if (key === "secretAWSTag") {
return (value as MetadataValue<"secretAWSTag">)!.map(({ key: tagKey, value: tagValue }) => (
<p key={tagKey} className="text-sm text-gray-200">
{tagKey}={tagValue}
</p>
));
}
if (key === "githubVisibilityRepoIds") {
return value.join(", ");
}
}
if (typeof value === "string") {
return value.length ? value : "N/A";
}
if (typeof value === "number") {
return value;
}
return null;
};
if (!integration.metadata || Object.keys(integration.metadata).length === 0) {
return null;
}
// eslint-disable-next-line no-nested-ternary
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Settings</p>
</div>
<div className="grid grid-cols-2 gap-4">
{integration.metadata &&
Object.entries(integration.metadata).map(([key, value]) => (
<div key={key} className="flex flex-col">
<p className="text-sm text-gray-400">
{metadataMappings[key as keyof typeof metadataMappings]}
</p>
<p className="text-sm text-gray-200">{renderValue(key as MetadataKey, value)}</p>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1 @@
export { IntegrationDetailsPage } from "./IntegrationDetailsPage";
@@ -1,6 +1,10 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useRouter } from "next/router";
import {
faArrowRight,
faCalendarCheck,
faEllipsis,
faRefresh,
faWarning,
faXmark
@@ -10,7 +14,7 @@ import { format } from "date-fns";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormLabel, IconButton, Tag, Tooltip } from "@app/components/v2";
import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
@@ -28,9 +32,12 @@ export const ConfiguredIntegrationItem = ({
onRemoveIntegration,
onManualSyncIntegration
}: IProps) => {
const router = useRouter();
return (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
onClick={() => router.push(`/integrations/details/${integration.id}`)}
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
@@ -168,9 +175,9 @@ export const ConfiguredIntegrationItem = ({
</div>
)}
</div>
<div className="mt-[1.5rem] flex cursor-default">
<div className="mt-[1.5rem] flex cursor-default space-x-3">
{integration.isSynced != null && integration.lastUsed != null && (
<Tag key={integration.id} className={integration.isSynced ? "bg-green-800" : "bg-red/80"}>
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<Tooltip
center
className="max-w-xs whitespace-normal break-words"
@@ -178,7 +185,7 @@ export const ConfiguredIntegrationItem = ({
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last sync</div>
<div className="text-sm">Last successful sync</div>
</div>
<div className="pl-5 text-left text-xs">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
@@ -195,43 +202,62 @@ export const ConfiguredIntegrationItem = ({
</div>
}
>
<div className="flex items-center space-x-2 text-white">
<div className="flex h-full items-center space-x-2">
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
</div>
</Tooltip>
</Tag>
</Badge>
)}
<div className="mr-1 flex items-end opacity-80 duration-200 hover:opacity-100">
<div className="space-x-1.5">
<Tooltip className="text-center" content="Manually sync integration secrets">
<Button
onClick={() => onManualSyncIntegration()}
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faRefresh} className="px-1 text-bunker-200" />
</Button>
<FontAwesomeIcon icon={faRefresh} className="px-1" />
</IconButton>
</Tooltip>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<div className="flex items-end opacity-80 duration-200 hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={() => onRemoveIntegration()}
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faXmark} className="px-0.5" />
<FontAwesomeIcon icon={faXmark} className="px-1" />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
)}
</ProjectPermissionCan>
<Tooltip content="View details">
<IconButton
ariaLabel="delete"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
</IconButton>
</Tooltip>
</div>
</div>
</div>
);
@@ -0,0 +1,21 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { LogsSection } from "./components";
export const AuditLogsPage = withPermission(
() => {
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="bg-bunker-800 py-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div />
</div>
<LogsSection filterClassName="static p-2" showFilters isOrgAuditLogs />
</div>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs }
);
@@ -0,0 +1,338 @@
/* eslint-disable no-nested-ternary */
import { useState } from "react";
import { Control, Controller, UseFormReset, UseFormWatch } from "react-hook-form";
import {
faCheckCircle,
faChevronDown,
faFilterCircleXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
Button,
DatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetAuditLogActorFilterOpts } from "@app/hooks/api";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
import { Actor } from "@app/hooks/api/auditLogs/types";
import { AuditLogFilterFormData } from "./types";
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
label,
value
}));
type Props = {
presets?: {
actorId?: string;
eventType?: EventType[];
};
className?: string;
isOrgAuditLogs?: boolean;
control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>;
watch: UseFormWatch<AuditLogFilterFormData>;
};
export const LogsFilter = ({
presets,
isOrgAuditLogs,
className,
control,
reset,
watch
}: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const { currentWorkspace, workspaces } = useWorkspace();
const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? "");
const renderActorSelectItem = (actor: Actor) => {
switch (actor.type) {
case ActorType.USER:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.userId}`}
key={`user-actor-filter-${actor.metadata.userId}`}
>
{actor.metadata.email}
</SelectItem>
);
case ActorType.SERVICE:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.serviceId}`}
key={`service-actor-filter-${actor.metadata.serviceId}`}
>
{actor.metadata.name}
</SelectItem>
);
case ActorType.IDENTITY:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.identityId}`}
key={`identity-filter-${actor.metadata.identityId}`}
>
{actor.metadata.name}
</SelectItem>
);
default:
return (
<SelectItem value="actor-none" key="actor-none">
N/A
</SelectItem>
);
}
};
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
return (
<div
className={twMerge(
"sticky top-20 z-10 flex items-center justify-between bg-bunker-800",
className
)}
>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="eventType"
render={({ field }) => (
<FormControl label="Events">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label
: selectedEventTypes?.length === 0
? "All events"
: `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faChevronDown} className="ml-2 text-xs" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[100] max-h-80 overflow-hidden">
<div className="max-h-80 overflow-y-auto">
{eventTypes && eventTypes.length > 0 ? (
eventTypes.map((eventType) => {
const isSelected = selectedEventTypes?.includes(
eventType.value as EventType
);
return (
<DropdownMenuItem
onSelect={(event) => eventTypes.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedEventTypes?.includes(eventType.value as EventType)) {
field.onChange(
selectedEventTypes?.filter((e: string) => e !== eventType.value)
);
} else {
field.onChange([...(selectedEventTypes || []), eventType.value]);
}
}}
key={`event-type-${eventType.value}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{eventType.label}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
{!isLoading && data && data.length > 0 && !presets?.actorId && (
<Controller
control={control}
name="actor"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Actor"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{data.map((actor) => renderActorSelectItem(actor))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="userAgentType"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Source"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
value={value === undefined ? "all" : value}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
value === undefined && "text-mineshaft-400"
)}
>
<SelectItem value="all" key="all">
All sources
</SelectItem>
{userAgentTypes.map(({ label, value: userAgent }) => (
<SelectItem value={userAgent} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{isOrgAuditLogs && workspaces.length > 0 && (
<Controller
control={control}
name="projectId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
value={value === undefined ? "all" : value}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
value === undefined && "text-mineshaft-400"
)}
>
<SelectItem value="all" key="all">
All projects
</SelectItem>
{workspaces.map((project) => (
<SelectItem value={String(project.id || "")} key={project.id}>
{project.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={(date) => {
onChange(date);
setIsStartDatePickerOpen(false);
}}
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={(pickedDate) => {
pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch)
onChange(pickedDate);
setIsEndDatePickerOpen(false);
}}
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
</div>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-1.5"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
projectId: undefined
})
}
>
Clear filters
</Button>
</div>
);
};
@@ -5,24 +5,38 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { UpgradePlanModal } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { usePopUp } from "@app/hooks/usePopUp";
import { LogsFilter } from "./LogsFilter";
import { LogsTable } from "./LogsTable";
import { LogsTable, TAuditLogTableHeader } from "./LogsTable";
import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types";
type Props = {
presetActor?: string;
presets?: {
actorId?: string;
eventType?: EventType[];
actorType?: ActorType;
startDate?: Date;
endDate?: Date;
eventMetadata?: Record<string, string>;
};
showFilters?: boolean;
filterClassName?: string;
isOrgAuditLogs?: boolean;
showActorColumn?: boolean;
remappedHeaders?: Partial<Record<TAuditLogTableHeader, string>>;
refetchInterval?: number;
};
export const LogsSection = ({
presetActor,
presets,
filterClassName,
remappedHeaders,
isOrgAuditLogs,
showActorColumn,
refetchInterval,
showFilters
}: Props) => {
const { subscription } = useSubscription();
@@ -33,11 +47,13 @@ export const LogsSection = ({
const { control, reset, watch } = useForm<AuditLogFilterFormData>({
resolver: yupResolver(auditLogFilterFormSchema),
defaultValues: {
actor: presetActor,
projectId: undefined,
actor: presets?.actorId,
eventType: presets?.eventType || [],
page: 1,
perPage: 10,
startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // day before today
endDate: new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today
}
});
@@ -47,9 +63,10 @@ export const LogsSection = ({
}
}, [subscription]);
const eventType = watch("eventType") as EventType | undefined;
const eventType = watch("eventType") as EventType[] | undefined;
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor");
const projectId = watch("projectId");
const startDate = watch("startDate");
const endDate = watch("endDate");
@@ -58,20 +75,30 @@ export const LogsSection = ({
<div>
{showFilters && (
<LogsFilter
isOrgAuditLogs
className={filterClassName}
presetActor={presetActor}
presets={presets}
control={control}
watch={watch}
reset={reset}
/>
)}
<LogsTable
refetchInterval={refetchInterval}
remappedHeaders={remappedHeaders}
isOrgAuditLogs={isOrgAuditLogs}
eventType={eventType}
userAgentType={userAgentType}
showActorColumn={!presetActor}
actor={actor}
startDate={startDate}
endDate={endDate}
showActorColumn={!!showActorColumn && !isOrgAuditLogs}
filter={{
eventMetadata: presets?.eventMetadata,
projectId,
actorType: presets?.actorType,
limit: 15,
eventType,
userAgentType,
startDate,
endDate,
actorId: actor
}}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
@@ -15,43 +15,52 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetAuditLogs } from "@app/hooks/api";
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { TGetAuditLogsFilter } from "@app/hooks/api/auditLogs/types";
import { LogsTableRow } from "./LogsTableRow";
type Props = {
eventType?: EventType;
userAgentType?: UserAgentType;
actor?: string;
startDate?: Date;
endDate?: Date;
isOrgAuditLogs?: boolean;
showActorColumn: boolean;
filter?: TGetAuditLogsFilter;
remappedHeaders?: Partial<Record<TAuditLogTableHeader, string>>;
refetchInterval?: number;
};
const AUDIT_LOG_LIMIT = 15;
const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const;
export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number];
export const LogsTable = ({
eventType,
userAgentType,
showActorColumn,
actor,
startDate,
endDate,
isOrgAuditLogs
isOrgAuditLogs,
filter,
remappedHeaders,
refetchInterval
}: Props) => {
const { currentWorkspace } = useWorkspace();
// Determine the project ID for filtering
const filterProjectId =
// Use the projectId from the filter if it exists
filter?.projectId ??
// Otherwise, if we're not looking at org-wide audit logs
(!isOrgAuditLogs
? // Use the current workspace ID (or an empty string if that's null)
currentWorkspace?.id ?? ""
: // For org-wide audit logs, use null (no specific project filter)
null);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs(
{
eventType,
userAgentType,
actor,
startDate,
endDate,
...filter,
limit: AUDIT_LOG_LIMIT
},
!isOrgAuditLogs ? currentWorkspace?.id ?? "" : null
filterProjectId,
{
refetchInterval
}
);
const isEmpty = !isLoading && !data?.pages?.[0].length;
@@ -62,18 +71,24 @@ export const LogsTable = ({
<Table>
<THead>
<Tr>
<Th>Timestamp</Th>
<Th>Event</Th>
{isOrgAuditLogs && <Th>Project</Th>}
{showActorColumn && <Th>Actor</Th>}
<Th>Source</Th>
<Th>Metadata</Th>
{TABLE_HEADERS.map((header, idx) => {
if (
(header === "Project" && !isOrgAuditLogs) ||
(header === "Actor" && !showActorColumn)
) {
return null;
}
return (
<Th key={`table-header-${idx + 1}`}>{remappedHeaders?.[header] || header}</Th>
);
})}
</Tr>
</THead>
<TBody>
{!isLoading &&
data?.pages?.map((group, i) => (
<Fragment key={`auditlog-item-${i + 1}`}>
<Fragment key={`audit-log-fragment-${i + 1}`}>
{group.map((auditLog) => (
<LogsTableRow
showActorColumn={showActorColumn}
@@ -1,4 +1,4 @@
import { Td, Tr } from "@app/components/v2";
import { Badge, Td, Tooltip, Tr } from "@app/components/v2";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
import { Actor, AuditLog, Event } from "@app/hooks/api/auditLogs/types";
@@ -39,6 +39,8 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
};
const renderMetadata = (event: Event) => {
const metadataKeys = Object.keys(event.metadata);
switch (event.type) {
case EventType.GET_SECRETS:
return (
@@ -461,7 +463,62 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
<p>{`Secret Request Channels: ${event.metadata.secretRequestChannels}`}</p>
</Td>
);
case EventType.INTEGRATION_SYNCED:
return (
<Td>
<Tooltip
className="max-w-xs whitespace-normal break-words"
content={event.metadata.syncMessage!}
isDisabled={!event.metadata.syncMessage}
>
<Badge variant={event.metadata.isSynced ? "success" : "danger"}>
<p className="text-center">{event.metadata.isSynced ? "Successful" : "Failed"}</p>
</Badge>
</Tooltip>
</Td>
);
case EventType.GET_WORKSPACE_KEY:
return (
<Td>
<p>{`Key ID: ${event.metadata.keyId}`}</p>
</Td>
);
case EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH:
case EventType.ADD_IDENTITY_UNIVERSAL_AUTH:
case EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH:
case EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS:
return (
<Td>
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
</Td>
);
case EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
case EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
return (
<Td>
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
<p>{`Client Secret ID: ${event.metadata.clientSecretId}`}</p>
</Td>
);
// ? If for some reason non the above events are matched, we will display the first 3 metadata items in the metadata object.
default:
if (metadataKeys.length) {
const maxMetadataLength = metadataKeys.length > 3 ? 3 : metadataKeys.length;
return (
<Td>
{Object.entries(event.metadata)
.slice(0, maxMetadataLength)
.map(([key, value]) => {
return <p key={`audit-log-metadata-${key}`}>{`${key}: ${value}`}</p>;
})}
</Td>
);
}
return <Td />;
}
};
@@ -484,16 +541,41 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
return formattedDate;
};
return (
<Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}>
<Td>{formatDate(auditLog.createdAt)}</Td>
<Td>{`${eventToNameMap[auditLog.event.type]}`}</Td>
{isOrgAuditLogs && <Td>{auditLog.project.name}</Td>}
{showActorColumn && renderActor(auditLog.actor)}
const renderSource = () => {
const { event, actor } = auditLog;
if (event.type === EventType.INTEGRATION_SYNCED) {
if (actor.type === ActorType.USER) {
return (
<Td>
<p>Manually triggered by {actor.metadata.email}</p>
</Td>
);
}
// Platform / automatic syncs
return (
<Td>
<p>Automatically synced by Infisical</p>
</Td>
);
}
return (
<Td>
<p>{userAgentTTypeoNameMap[auditLog.userAgentType]}</p>
<p>{auditLog.ipAddress}</p>
</Td>
);
};
return (
<Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}>
<Td>{formatDate(auditLog.createdAt)}</Td>
<Td>{`${eventToNameMap[auditLog.event.type]}`}</Td>
{isOrgAuditLogs && <Td>{auditLog?.project?.name ?? "N/A"}</Td>}
{showActorColumn && renderActor(auditLog.actor)}
{renderSource()}
{renderMetadata(auditLog.event)}
</Tr>
);
@@ -4,7 +4,9 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
export const auditLogFilterFormSchema = yup
.object({
eventType: yup.string().oneOf(Object.values(EventType), "Invalid event type"),
eventMetadata: yup.object({}).optional(),
projectId: yup.string().optional(),
eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")),
actor: yup.string(),
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),
startDate: yup.date(),
@@ -42,6 +42,8 @@ export const IdentitySection = withPermission(
? subscription.identitiesUsed < subscription.identityLimit
: true;
const isEnterprise = subscription?.slug === "enterprise"
const onDeleteIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
@@ -93,7 +95,7 @@ export const IdentitySection = withPermission(
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (!isMoreIdentitiesAllowed) {
if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description: "You can add more identities if you upgrade your Infisical plan."
});
@@ -50,7 +50,7 @@ type Props = {
) => void;
};
const INIT_PER_PAGE = 10;
const INIT_PER_PAGE = 20;
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter();
@@ -277,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
{!isLoading && data && data.totalCount > 0 && (
<Pagination
count={data.totalCount}
page={page}
@@ -51,6 +51,8 @@ export const OrgMembersSection = () => {
? subscription.identitiesUsed < subscription.identityLimit
: true;
const isEnterprise = subscription?.slug === "enterprise";
const handleAddMemberModal = () => {
if (currentOrg?.authEnforced) {
createNotification({
@@ -60,7 +62,7 @@ export const OrgMembersSection = () => {
return;
}
if (!isMoreUsersAllowed || !isMoreIdentitiesAllowed) {
if ((!isMoreUsersAllowed || !isMoreIdentitiesAllowed) && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description: "You can add more members if you upgrade your Infisical plan."
});
@@ -32,6 +32,8 @@ export const formSchema = z.object({
create: z.boolean().optional()
})
.optional(),
"audit-logs": generalPermissionSchema,
member: generalPermissionSchema,
groups: generalPermissionSchema,
role: generalPermissionSchema,
@@ -41,6 +41,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "Incident Contacts",
formName: "incident-contact"
},
{
title: "Audit Logs",
formName: "audit-logs"
},
{
title: "Organization Profile",
formName: "settings"
@@ -7,7 +7,7 @@ import { EmptyState, IconButton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgUser } from "@app/hooks/api/types";
import { LogsSection } from "@app/views/Project/AuditLogsPage/components";
import { LogsSection } from "@app/views/Org/AuditLogsPage/components";
type Props = {
orgMembership: OrgUser;
@@ -42,7 +42,9 @@ export const UserAuditLogsSection = withPermission(
<LogsSection
showFilters={showFilter}
filterClassName="bg-mineshaft-900 static"
presetActor={orgMembership.user.id}
presets={{
actorId: orgMembership.user.id
}}
isOrgAuditLogs
/>
</div>
@@ -1,21 +0,0 @@
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { LogsSection } from "./components";
export const AuditLogsPage = withProjectPermission(
() => {
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="sticky top-0 z-10 bg-bunker-800 py-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div />
</div>
<LogsSection showFilters />
</div>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
);
@@ -1,222 +0,0 @@
import { useState } from "react";
import { Control, Controller, UseFormReset } from "react-hook-form";
import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, DatePicker, FormControl, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetAuditLogActorFilterOpts } from "@app/hooks/api";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { Actor } from "@app/hooks/api/auditLogs/types";
import { AuditLogFilterFormData } from "./types";
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
label,
value
}));
type Props = {
presetActor?: string;
className?: string;
control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>;
};
export const LogsFilter = ({ presetActor, className, control, reset }: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? "");
const renderActorSelectItem = (actor: Actor) => {
switch (actor.type) {
case ActorType.USER:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.userId}`}
key={`user-actor-filter-${actor.metadata.userId}`}
>
{actor.metadata.email}
</SelectItem>
);
case ActorType.SERVICE:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.serviceId}`}
key={`service-actor-filter-${actor.metadata.serviceId}`}
>
{actor.metadata.name}
</SelectItem>
);
case ActorType.IDENTITY:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.identityId}`}
key={`identity-filter-${actor.metadata.identityId}`}
>
{actor.metadata.name}
</SelectItem>
);
default:
return (
<SelectItem value="actor-none" key="actor-none">
N/A
</SelectItem>
);
}
};
return (
<div
className={twMerge(
"sticky top-20 z-10 flex items-center justify-between bg-bunker-800",
className
)}
>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="eventType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Event"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{eventTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isLoading && data && data.length > 0 && !presetActor && (
<Controller
control={control}
name="actor"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Actor"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{data.map((actor) => renderActorSelectItem(actor))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="userAgentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Source"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{userAgentTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={(date) => {
onChange(date);
setIsStartDatePickerOpen(false);
}}
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={(pickedDate) => {
pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch)
onChange(pickedDate);
setIsEndDatePickerOpen(false);
}}
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
</div>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-1.5"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: undefined,
actor: presetActor,
userAgentType: undefined,
startDate: undefined,
endDate: undefined
})
}
>
Clear filters
</Button>
</div>
);
};
@@ -56,7 +56,7 @@ import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const INIT_PER_PAGE = 10;
const INIT_PER_PAGE = 20;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
@@ -369,7 +369,7 @@ export const IdentityTab = withProjectPermission(
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
{!isLoading && data && data.totalCount > 0 && (
<Pagination
count={data.totalCount}
page={page}
@@ -46,12 +46,12 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above
});
const identityMemberships = identityMembershipsData?.identityMemberships;
@@ -47,7 +47,6 @@ export const formSchema = z.object({
settings: generalPermissionSchema,
environments: generalPermissionSchema,
tags: generalPermissionSchema,
"audit-logs": generalPermissionSchema,
"ip-allowlist": generalPermissionSchema,
"certificate-authorities": generalPermissionSchema,
certificates: generalPermissionSchema,
@@ -65,10 +65,6 @@ const SINGLE_PERMISSION_LIST = [
title: "Tags",
formName: "tags"
},
{
title: "Audit Logs",
formName: "audit-logs"
},
{
title: "IP Allowlist",
formName: "ip-allowlist"
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
@@ -17,29 +18,28 @@ import {
} from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import {
useGetDynamicSecrets,
useGetImportedSecretsSingleEnv,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretApprovalPolicyOfABoard,
useGetSecretImports,
useGetWorkspaceSnapshotList,
useGetWsSnapshotCount,
useGetWsTags
} from "@app/hooks/api";
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView";
import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView";
import { SecretImportListView } from "@app/views/SecretMainPage/components/SecretImportListView";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount";
import { SecretV2MigrationSection } from "../SecretOverviewPage/components/SecretV2MigrationSection";
import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store";
import { Filter, SortDir } from "./SecretMainPage.types";
import { Filter, RowType } from "./SecretMainPage.types";
const LOADER_TEXT = [
"Retrieving your encrypted secrets...",
@@ -47,7 +47,7 @@ const LOADER_TEXT = [
"Getting secret import links..."
];
const INIT_PER_PAGE = 10;
const INIT_PER_PAGE = 20;
export const SecretMainPage = () => {
const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
@@ -55,12 +55,8 @@ export const SecretMainPage = () => {
const { permission } = useProjectPermission();
const [isVisible, setIsVisible] = useState(false);
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
const [filter, setFilter] = useState<Filter>({
tags: {},
searchFilter: (router.query.searchFilter as string) || ""
});
const debouncedSearchFilter = useDebounce(filter.searchFilter);
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const paginationOffset = (page - 1) * perPage;
@@ -83,6 +79,18 @@ export const SecretMainPage = () => {
ProjectPermissionSub.SecretRollback
);
const [filter, setFilter] = useState<Filter>({
tags: {},
searchFilter: (router.query.searchFilter as string) || "",
include: {
[RowType.Folder]: true,
[RowType.Import]: canReadSecret,
[RowType.DynamicSecret]: canReadSecret,
[RowType.Secret]: canReadSecret
}
});
const debouncedSearchFilter = useDebounce(filter.searchFilter);
useEffect(() => {
if (
!isWorkspaceLoading &&
@@ -91,43 +99,43 @@ export const SecretMainPage = () => {
) {
router.push(`/project/${workspaceId}/secrets/overview`);
createNotification({
text: "No envronment found with given slug",
text: "No environment found with given slug",
type: "error"
});
}
}, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]);
// fetch secrets
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
environment,
workspaceId,
secretPath,
options: {
enabled: canReadSecret
}
});
// fetch folders
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
projectId: workspaceId,
environment,
path: secretPath
});
// fetch secret imports
const {
data: secretImports,
isLoading: isSecretImportsLoading,
isFetching: isSecretImportsFetching
} = useGetSecretImports({
projectId: workspaceId,
data,
isLoading: isDetailsLoading,
isFetching: isDetailsFetching
} = useGetProjectSecretsDetails({
environment,
path: secretPath,
options: {
enabled: canReadSecret
}
projectId: workspaceId,
secretPath,
offset: paginationOffset,
limit: perPage,
search: debouncedSearchFilter,
orderDirection,
includeImports: canReadSecret && filter.include.import,
includeFolders: filter.include.folder,
includeDynamicSecrets: canReadSecret && filter.include.dynamic,
includeSecrets: canReadSecret && filter.include.secret,
tags: filter.tags
});
const {
imports,
folders,
dynamicSecrets,
secrets,
totalImportCount = 0,
totalFolderCount = 0,
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0
} = data ?? {};
// fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId,
@@ -138,13 +146,7 @@ export const SecretMainPage = () => {
}
});
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({
projectSlug,
environmentSlug: environment,
path: secretPath
});
// fech tags
// fetch tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
@@ -174,12 +176,14 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback
});
const isNotEmtpy = Boolean(
secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length
const isNotEmpty = Boolean(
secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length
);
const handleSortToggle = () =>
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
setOrderDirection((state) =>
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
const handleEnvChange = (slug: string) => {
const query: Record<string, string> = { ...router.query, env: slug };
@@ -191,17 +195,31 @@ export const SecretMainPage = () => {
};
const handleTagToggle = useCallback(
(tagId: string) =>
(tagSlug: string) =>
setFilter((state) => {
const isTagPresent = Boolean(state.tags?.[tagId]);
const isTagPresent = Boolean(state.tags?.[tagSlug]);
const newTagFilter = { ...state.tags };
if (isTagPresent) delete newTagFilter[tagId];
else newTagFilter[tagId] = true;
if (isTagPresent) delete newTagFilter[tagSlug];
else newTagFilter[tagSlug] = true;
return { ...state, tags: newTagFilter };
}),
[]
);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
include: {
...state.include,
[rowType]: !state.include[rowType]
}
};
}),
[]
);
const handleSearchChange = useCallback(
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
[]
@@ -219,132 +237,32 @@ export const SecretMainPage = () => {
handlePopUpClose("snapshots");
}, []);
// loading screen when u have permission
const loadingOnAccess =
canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);
const rows = useMemo(() => {
const filteredSecrets =
secrets
?.filter(({ key, tags: secretTags, value }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
return (
(!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) &&
(key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) ||
value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
);
})
.sort((a, b) =>
sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key)
) ?? [];
const filteredFolders =
folders
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredDynamicSecrets =
dynamicSecrets
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredSecretImports =
secretImports
?.filter(({ importPath }) =>
importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase())
)
.sort((a, b) =>
sortDir === "asc"
? a.importPath.localeCompare(b.importPath)
: b.importPath.localeCompare(a.importPath)
) ?? [];
const totalRows =
filteredSecretImports.length +
filteredFolders.length +
filteredDynamicSecrets.length +
filteredSecrets.length;
const paginatedImports = filteredSecretImports.slice(
paginationOffset,
paginationOffset + perPage
);
let remainingRows = perPage - paginatedImports.length;
const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length);
const paginatedFolders =
remainingRows > 0
? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows)
: [];
remainingRows -= paginatedFolders.length;
const dynamicSecretStartIndex = Math.max(
0,
paginationOffset - filteredSecretImports.length - filteredFolders.length
);
const paginatiedDynamicSecrets =
remainingRows > 0
? filteredDynamicSecrets.slice(
dynamicSecretStartIndex,
dynamicSecretStartIndex + remainingRows
)
: [];
remainingRows -= paginatiedDynamicSecrets.length;
const secretStartIndex = Math.max(
0,
paginationOffset -
filteredSecretImports.length -
filteredFolders.length -
filteredDynamicSecrets.length
);
const paginatiedSecrets =
remainingRows > 0
? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows)
: [];
return {
imports: paginatedImports,
folders: paginatedFolders,
secrets: paginatiedSecrets,
dynamicSecrets: paginatiedDynamicSecrets,
totalRows
};
}, [
sortDir,
debouncedSearchFilter,
folders,
secrets,
dynamicSecrets,
paginationOffset,
perPage,
filter.tags,
importedSecrets
]);
useEffect(() => {
// reset page if no longer valid
if (rows.totalRows < paginationOffset) setPage(1);
}, [rows.totalRows]);
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
// loading screen when you don't have permission but as folder's is viewable need to wait for that
const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) {
if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
return (
<StoreProvider>
<div className="container mx-auto flex h-full flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader
pageName={t("dashboard.title")}
currentEnv={environment}
userAvailableEnvs={currentWorkspace?.environments}
userAvailableEnvs={currentWorkspace?.environments.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
)}
isFolderMode
secretPath={secretPath}
isProjectRelated
@@ -364,17 +282,22 @@ export const SecretMainPage = () => {
isVisible={isVisible}
filter={filter}
tags={tags}
onVisiblilityToggle={handleToggleVisibility}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
{isNotEmtpy && (
<div className="flex border-b border-mineshaft-600 font-medium">
{isNotEmpty && (
<div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
>
<div style={{ width: "2.8rem" }} className="flex-shrink-0 px-4 py-3" />
<div
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
@@ -387,45 +310,43 @@ export const SecretMainPage = () => {
>
Key
<FontAwesomeIcon
icon={sortDir === SortDir.ASC ? faArrowDown : faArrowUp}
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{canReadSecret && (
{canReadSecret && imports?.length && (
<SecretImportListView
searchTerm={filter.searchFilter}
secretImports={rows.imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching}
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
secrets={secrets}
importedSecrets={importedSecrets}
/>
)}
<FolderListView
folders={rows.folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
sortDir={sortDir}
searchTerm={filter.searchFilter}
/>
{canReadSecret && (
{folders?.length && (
<FolderListView
folders={folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
/>
)}
{canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView
sortDir={sortDir}
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={rows.dynamicSecrets || []}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecret && (
{canReadSecret && secrets?.length && (
<SecretListView
secrets={rows.secrets}
secrets={secrets}
tags={tags}
isVisible={isVisible}
environment={environment}
@@ -435,18 +356,26 @@ export const SecretMainPage = () => {
/>
)}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
{!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.totalRows}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div>
</div>
{!isDetailsLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
<CreateSecretForm
environment={environment}
workspaceId={workspaceId}
@@ -459,7 +388,7 @@ export const SecretMainPage = () => {
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isNotEmtpy}
isSmaller={isNotEmpty}
environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/>
@@ -1,15 +1,14 @@
export type Filter = {
tags: Record<string, boolean>;
searchFilter: string;
include: {
[key in RowType]: boolean;
};
};
export enum SortDir {
ASC = "asc",
DESC = "desc"
}
export enum RowType {
Folder = "folder",
Import = "import",
DynamicSecret = "dynamic",
Secret = "Secret"
Secret = "secret"
}
@@ -13,7 +13,9 @@ import {
faFileImport,
faFilter,
faFingerprint,
faFolder,
faFolderPlus,
faKey,
faMagnifyingGlass,
faMinusSquare,
faPlus,
@@ -62,7 +64,7 @@ import {
useSelectedSecretActions,
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter } from "../../SecretMainPage.types";
import { Filter, RowType } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
@@ -83,7 +85,8 @@ type Props = {
isSnapshotCountLoading?: boolean;
onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void;
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
};
@@ -100,8 +103,9 @@ export const ActionBar = ({
isSnapshotCountLoading,
onSearchChange,
onToggleTagFilter,
onVisiblilityToggle,
onClickRollbackMode
onVisibilityToggle,
onClickRollbackMode,
onToggleRowType
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -298,7 +302,9 @@ export const ActionBar = ({
ariaLabel="Download"
className={twMerge(
"transition-all",
Object.keys(filter.tags).length && "border-primary/50 text-primary"
(Object.keys(filter.tags).length ||
Object.values(filter.include).filter((include) => !include).length) &&
"border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
@@ -306,6 +312,60 @@ export const ActionBar = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Import);
}}
icon={filter?.include[RowType.Import] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className=" text-green-700" />
<span>Imports</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Folder);
}}
icon={filter?.include[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.DynamicSecret);
}}
icon={
filter?.include[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className=" text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Secret);
}}
icon={filter?.include[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className=" text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
@@ -319,10 +379,10 @@ export const ActionBar = ({
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onToggleTagFilter(id);
onToggleTagFilter(slug);
}}
key={id}
icon={filter?.tags[id] && <FontAwesomeIcon icon={faCheckCircle} />}
icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
@@ -346,7 +406,7 @@ export const ActionBar = ({
</IconButton>
</div>
<div>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisibilityToggle}>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>
@@ -0,0 +1,355 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen, faCheckCircle, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input
} from "@app/components/v2";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/v2/Dropdown/Dropdown";
import { Tooltip } from "@app/components/v2/Tooltip";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
selectedUsers: z.array(z.object({
id: z.string().min(1),
name: z.string().min(1),
email: z.string().min(1),
})),
provider: z.object({
tenantId: z.string().min(1),
applicationId: z.string().min(1),
clientSecret: z.string().min(1)
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().min(1).refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AzureEntraIdInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const tenantId = watch("provider.tenantId");
const applicationId = watch("provider.applicationId");
const clientSecret = watch("provider.clientSecret");
const configurationComplete = !!(tenantId && applicationId && clientSecret);
const { data, isLoading, isError, isFetching } = useGetDynamicSecretProviderData({ tenantId, applicationId, clientSecret, enabled: !!configurationComplete });
const loading = configurationComplete && isFetching;
const errored = configurationComplete && !isFetching && isError;
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, selectedUsers, provider, maxTTL, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
selectedUsers.map(async (user: { id: string, name: string, email: string }) => {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: provider.tenantId, email: user.email, applicationId: provider.applicationId, clientSecret: provider.clientSecret } },
maxTTL,
name: `${name}-${user.name}`,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Prefix"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<Link href="https://infisical.com/docs/documentation/platform/dynamic-secrets/azure-entra-id" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Tenant Id from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.applicationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Application ID from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Client Secret from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Select Users
</div>
<div className="mb-4 flex items-center text-sm font-normal text-mineshaft-400">
&nbsp; We create a unique dynamic secret for each user in Entra Id.
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-4">
<Controller
control={control}
name="selectedUsers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu >
<DropdownMenuTrigger
className="w-72"
disabled={loading || errored || !configurationComplete}
>
<Tooltip
hidden={!loading && !errored && configurationComplete}
content=
{
<div>
{(() => {
let icon;
if (errored) {
icon = <FontAwesomeIcon icon={faWarning} color="red" />;
} else if (loading || !configurationComplete) {
icon = <FontAwesomeIcon icon={faWarning} color="yellow" />;
} else {
icon = null;
}
return icon;
})()}
<span className="ml-4 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{(() => {
let message;
if (loading) {
message = "Loading, please wait...";
} else if (errored) {
message = "Check the configuration";
} else if (!configurationComplete) {
message = "Configuration incomplete";
} else {
message = ""; // or you can leave it undefined
}
return message;
})()}
</span>
</div>
}
>
<div>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : ""}
className={`text-left ${loading || errored || !configurationComplete ? "cursor-not-allowed" : ""}`}
placeholder="Select users"
/>
</div>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="start"
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
>
{data && data.map((user) => {
const ids = value?.map((selectedUser) => selectedUser.id)
const isChecked = ids?.includes(user.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el) => el.id !== user.id)
: [...(value || []), user]
);
}}
key={`create-policy-members-${user.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.name} <br /> {`(${user.email})`}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isLoading || isError}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -1,6 +1,6 @@
import { useState } from "react";
import { DiRedis } from "react-icons/di";
import { SiApachecassandra, SiElasticsearch, SiMongodb, SiRabbitmq } from "react-icons/si";
import { SiApachecassandra, SiElasticsearch, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -11,6 +11,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -77,6 +78,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <SiRabbitmq size="1.5rem" />,
provider: DynamicSecretProviders.RabbitMq,
title: "RabbitMQ"
},
{
icon: <SiMicrosoftazure size="1.5rem" />,
provider: DynamicSecretProviders.AzureEntraId,
title: "Azure Entra ID",
}
];
@@ -300,6 +306,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AzureEntraId && (
<motion.div
key="dynamic-azure-entra-id-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AzureEntraIdInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)
}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -176,6 +176,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
);
}
if (provider === DynamicSecretProviders.AzureEntraId) {
const { email, password } = data as {
email: string;
password: string;
};
return (
<div>
<OutputDisplay label="Email" value={email} />
<OutputDisplay
label="Password"
value={password}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};
@@ -27,7 +27,6 @@ import {
TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types";
import { SortDir } from "../../SecretMainPage.types";
import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease";
import { DynamicSecretLease } from "./DynamicSecretLease";
import { EditDynamicSecretForm } from "./EditDynamicSecretForm";
@@ -38,19 +37,17 @@ const formatProviderName = (type: DynamicSecretProviders) => {
};
type Props = {
dynamicSecrets: TDynamicSecret[];
dynamicSecrets?: TDynamicSecret[];
environment: string;
projectSlug: string;
secretPath?: string;
sortDir: SortDir;
};
export const DynamicSecretListView = ({
dynamicSecrets = [],
environment,
projectSlug,
secretPath = "/",
sortDir = SortDir.ASC
secretPath = "/"
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"dynamicSecretLeases",
@@ -59,7 +56,6 @@ export const DynamicSecretListView = ({
"deleteDynamicSecret"
] as const);
const deleteDynamicSecret = useDeleteDynamicSecret();
const handleDynamicSecretDelete = async () => {
@@ -90,158 +86,148 @@ export const DynamicSecretListView = ({
return (
<>
{dynamicSecrets
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.map((secret) => {
const isRevocking = secret.status === DynamicSecretStatus.Deleting;
return (
<Modal
key={secret.id}
isOpen={
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
{dynamicSecrets.map((secret) => {
const isRevoking = secret.status === DynamicSecretStatus.Deleting;
return (
<Modal
key={secret.id}
isOpen={
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevoking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevoking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevocking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevocking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</div>
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
>
{secret.name}
<Tag className="ml-4 py-0 px-2 text-xs normal-case">
{formatProviderName(secret.type)}
</Tag>
{Boolean(secret.status) && (
<Tooltip content={secret?.statusDetails || secret.status || ""}>
<FontAwesomeIcon
className={
secret.status === DynamicSecretStatus.Deleting
? "text-yellow-600"
: "text-red-600"
}
icon={faWarning}
/>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-2 px-4 py-2">
<Button
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
{secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button
size="xs"
className="m-0 py-0.5 px-2"
colorSchema="danger"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", {
...secret,
isForced: true
});
}}
>
Force Delete
</Button>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-dynamic-secret"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("updateDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</div>
<ModalContent
title="Dynamic secret leases"
subTitle="Revoke or renew your secret leases"
className="max-w-3xl"
>
<DynamicSecretLease
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
key={secret.id}
dynamicSecretName={secret.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
);
})}
<div className="flex flex-grow items-center px-4 py-3" role="button" tabIndex={0}>
{secret.name}
<Tag className="ml-4 py-0 px-2 text-xs normal-case">
{formatProviderName(secret.type)}
</Tag>
{Boolean(secret.status) && (
<Tooltip content={secret?.statusDetails || secret.status || ""}>
<FontAwesomeIcon
className={
secret.status === DynamicSecretStatus.Deleting
? "text-yellow-600"
: "text-red-600"
}
icon={faWarning}
/>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-2 px-4 py-2">
<Button
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevoking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
{secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button
size="xs"
className="m-0 py-0.5 px-2"
colorSchema="danger"
isDisabled={isRevoking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", {
...secret,
isForced: true
});
}}
>
Force Delete
</Button>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-dynamic-secret"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("updateDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
<ModalContent
title="Dynamic secret leases"
subTitle="Revoke or renew your secret leases"
className="max-w-3xl"
>
<DynamicSecretLease
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
key={secret.id}
dynamicSecretName={secret.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
);
})}
<Modal
isOpen={popUp.createDynamicSecretLease.isOpen}
onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)}
@@ -0,0 +1,283 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
SecretInput,
} from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z.object({
email: z.string(),
userId: z.string(),
tenantId: z.string(),
applicationId: z.string(),
clientSecret: z.string()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretAzureEntraIdForm = ({
onClose,
dynamicSecret,
secretPath,
environment,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ maxTTL, defaultTTL, newName, inputs }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
newName: newName === dynamicSecret.name ? undefined : newName,
inputs
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.email"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Email"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.userId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isReadOnly
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isReadOnly
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.applicationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -6,6 +6,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
@@ -202,6 +203,24 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.AzureEntraId && (
<motion.div
key="azure-entra-id-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretAzureEntraIdForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -11,7 +11,6 @@ import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { SortDir } from "../../SecretMainPage.types";
import { FolderForm } from "../ActionBar/FolderForm";
type Props = {
@@ -19,17 +18,13 @@ type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
sortDir: SortDir;
searchTerm?: string;
};
export const FolderListView = ({
folders = [],
environment,
workspaceId,
searchTerm,
secretPath = "/",
sortDir = SortDir.ASC
secretPath = "/"
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder",
@@ -104,84 +99,77 @@ export const FolderListView = ({
return (
<>
{folders
.filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase())))
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.map(({ name, id }) => (
<div
key={id}
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
</div>
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
}}
onClick={() => handleFolderClick(name)}
>
{name}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
{folders.map(({ name, id }) => (
<div
key={id}
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
</div>
))}
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
}}
onClick={() => handleFolderClick(name)}
>
{name}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
))}
<Modal
isOpen={popUp.updateFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
@@ -14,9 +14,10 @@ import { Button, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/types";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
@@ -190,6 +191,9 @@ export const SecretDropzone = ({
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
handlePopUpClose("overlapKeyWarning");
createNotification({
@@ -291,7 +291,7 @@ export const SecretImportItem = ({
<tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
{/* <td style={{ padding: "0.25rem 1rem" }}>Override</td> */}
</tr>
</thead>
<tbody>
@@ -304,7 +304,7 @@ export const SecretImportItem = ({
)}
{importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => (
.map(({ key, value }, index) => (
<tr key={`${id}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
@@ -312,9 +312,9 @@ export const SecretImportItem = ({
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly />
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{/* <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td>
</td> */}
</tr>
))}
</tbody>
@@ -90,18 +90,18 @@ type Props = {
secretPath?: string;
secretImports?: TSecretImport[];
isFetching?: boolean;
secrets?: SecretV3RawSanitized[];
// secrets?: SecretV3RawSanitized[];
importedSecrets?: TImportedSecrets;
searchTerm: string;
};
export const SecretImportListView = ({
secretImports = [],
secretImports,
environment,
workspaceId,
secretPath,
importedSecrets,
secrets = [],
// secrets = [],
isFetching,
searchTerm
}: Props) => {
@@ -117,11 +117,11 @@ export const SecretImportListView = ({
useSensor(KeyboardSensor, {})
);
const [items, setItems] = useState(secretImports);
const [items, setItems] = useState(secretImports ?? []);
useEffect(() => {
if (!isFetching) {
setItems(secretImports);
setItems(secretImports ?? []);
}
}, [isFetching, secretImports]);
@@ -170,7 +170,7 @@ export const SecretImportListView = ({
};
const handleOpenReplicationSecrets = (replicationImportId: string) => {
const reservedImport = secretImports.find(
const reservedImport = secretImports?.find(
({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment &&
isReserved &&
@@ -208,8 +208,8 @@ export const SecretImportListView = ({
importedSecrets={computeImportedSecretRows(
item.importEnv.slug,
item.importPath,
importedSecrets,
secrets
importedSecrets
// secrets scott - now that secrets are paginated we are not showing if they are overridden (yet?)
)}
secretPath={secretPath}
environment={environment}
@@ -7,6 +7,7 @@ import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -213,6 +214,12 @@ export const SecretListView = ({
});
if (cb) cb();
}
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId: workspaceId,
secretPath
})
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -246,6 +253,9 @@ export const SecretListView = ({
try {
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
// wrap this in another function and then reuse
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -1,20 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faArrowDown,
faArrowUp,
faFingerprint,
faFolder,
faFolderBlank,
faFolderPlus,
faKey,
faList,
faMagnifyingGlass,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
@@ -55,22 +59,25 @@ import {
useCreateFolder,
useCreateSecretV3,
useDeleteSecretV3,
useGetDynamicSecretsOfAllEnv,
useGetFoldersByEnv,
useGetImportedSecretsAllEnvs,
useGetProjectSecretsAllEnv,
useUpdateSecretV3
} from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { SecretType, TSecretFolder } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
import { SecretOverviewTableRow } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount";
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection";
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
@@ -82,10 +89,13 @@ export enum EntryType {
enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
Secret = "secret"
}
const INIT_PER_PAGE = 10;
type Filter = {
[key in RowType]: boolean;
};
const INIT_PER_PAGE = 20;
export const SecretOverviewPage = () => {
const { t } = useTranslation();
@@ -96,7 +106,7 @@ export const SecretOverviewPage = () => {
// coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const { permission } = useProjectPermission();
useEffect(() => {
@@ -106,6 +116,7 @@ export const SecretOverviewPage = () => {
}, [parentTableRef.current]);
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3;
const { currentOrg } = useOrganization();
const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string;
@@ -113,6 +124,12 @@ export const SecretOverviewPage = () => {
const debouncedSearchFilter = useDebounce(searchFilter);
const secretPath = (router.query?.secretPath as string) || "/";
const [filter, setFilter] = useState<Filter>({
[RowType.Folder]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true
});
const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
@@ -173,28 +190,22 @@ export const SecretOverviewPage = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = currentWorkspace?.environments || [];
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
const readableEnvs = userAvailableEnvs?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
);
const [visibleEnvs, setVisibleEnvs] = useState(readableEnvs);
useEffect(() => {
setVisibleEnvs(userAvailableEnvs);
}, [userAvailableEnvs]);
const {
data: secrets,
getSecretByKey,
secKeys,
getEnvSecretKeyCount
} = useGetProjectSecretsAllEnv({
workspaceId,
envs: userAvailableEnvs.map(({ slug }) => slug),
secretPath
});
const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({
projectId: workspaceId,
path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug)
});
setVisibleEnvs(readableEnvs);
}, [userAvailableEnvs, secretPath]);
const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({
projectId: workspaceId,
@@ -202,12 +213,41 @@ export const SecretOverviewPage = () => {
environments: userAvailableEnvs.map(({ slug }) => slug)
});
const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } =
useGetDynamicSecretsOfAllEnv({
projectSlug,
environmentSlugs: userAvailableEnvs.map(({ slug }) => slug),
path: secretPath
});
const paginationOffset = (page - 1) * perPage;
const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
{
projectId: workspaceId,
environments: visibleEnvs.map((env) => env.slug),
secretPath,
orderDirection,
orderBy: DashboardSecretsOrderBy.Name,
includeFolders: filter.folder,
includeDynamicSecrets: filter.dynamic,
includeSecrets: filter.secret,
search: debouncedSearchFilter,
limit: perPage,
offset: paginationOffset
},
{ enabled: isProjectV3 }
);
const {
secrets,
folders,
dynamicSecrets,
totalCount = 0,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount
} = overview ?? {};
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets);
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
@@ -452,40 +492,23 @@ export const SecretOverviewPage = () => {
}
};
const rows = useMemo(() => {
const filteredSecretNames =
secKeys
?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredFolderNames =
folderNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredDynamicSecrets =
dynamicSecretNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
return [
...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })),
...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })),
...filteredSecretNames.map((name) => ({ name, type: RowType.Secret }))
];
}, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]);
const paginationOffset = (page - 1) * perPage;
useEffect(() => {
// reset page if no longer valid
if (rows.length < paginationOffset) setPage(1);
}, [rows.length]);
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
const isTableLoading =
folders?.some(({ isLoading }) => isLoading) ||
secrets?.some(({ isLoading }) => isLoading) ||
dynamicSecrets?.some(({ isLoading }) => isLoading);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
[rowType]: !state[rowType]
};
}),
[]
);
if (isWorkspaceLoading || isTableLoading) {
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img
@@ -504,17 +527,22 @@ export const SecretOverviewPage = () => {
// This is needed to also show imports from other paths – right now those are missing.
// const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()];
const isTableEmpty =
!(
folders?.every(({ isLoading }) => isLoading) &&
secrets?.every(({ isLoading }) => isLoading) &&
dynamicSecrets?.every(({ isLoading }) => isLoading)
) && rows.length === 0;
const isTableEmpty = totalCount === 0;
const isTableFiltered =
Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
visibleEnvs.length !== readableEnvs?.length;
if (!isProjectV3)
return (
<div className="flex h-full w-full flex-col items-center justify-center px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
</div>
);
return (
<>
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
@@ -571,7 +599,10 @@ export const SecretOverviewPage = () => {
ariaLabel="Environments"
variant="plain"
size="sm"
className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10"
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"
)}
>
<Tooltip content="Choose visible environments" className="mb-2">
<FontAwesomeIcon icon={faList} />
@@ -580,22 +611,20 @@ export const SecretOverviewPage = () => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((availableEnv) => {
{readableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={() => handleEnvSelect(envId)}
onClick={(e) => {
e.preventDefault();
handleEnvSelect(envId);
}}
key={envId}
icon={
isEnvSelected ? (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
) : (
<FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} />
)
}
iconPos="left"
disabled={visibleEnvs?.length === 1}
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">{name}</div>
</DropdownMenuItem>
@@ -613,6 +642,48 @@ export const SecretOverviewPage = () => {
Create an environment
</Button>
</DropdownMenuItem> */}
<DropdownMenuLabel>Filter project resources</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Folder);
}}
icon={filter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.DynamicSecret);
}}
icon={
filter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className=" text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Secret);
}}
icon={filter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className=" text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -697,9 +768,17 @@ export const SecretOverviewPage = () => {
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
onClick={() =>
setOrderDirection((prev) =>
prev === OrderByDirection.ASC
? OrderByDirection.DESC
: OrderByDirection.ASC
)
}
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
<FontAwesomeIcon
icon={orderDirection === "asc" ? faArrowDown : faArrowUp}
/>
</IconButton>
</div>
</Th>
@@ -736,7 +815,7 @@ export const SecretOverviewPage = () => {
</Tr>
</THead>
<TBody>
{canViewOverviewPage && isTableLoading && (
{canViewOverviewPage && isOverviewLoading && (
<TableSkeleton
columns={visibleEnvs.length + 1}
innerKey="secret-overview-loading"
@@ -778,7 +857,7 @@ export const SecretOverviewPage = () => {
</Td>
</Tr>
)}
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && (
{isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
@@ -803,60 +882,51 @@ export const SecretOverviewPage = () => {
</Td>
</Tr>
)}
{!isTableLoading &&
rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => {
switch (row.type) {
case RowType.Secret:
if (visibleEnvs?.length === 0) return null;
return (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[row.name]}
onToggleSecretSelect={() =>
toggleSelectedEntry(EntryType.SECRET, row.name)
}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${row.name}-${index + 1}`}
environments={visibleEnvs}
secretKey={row.name}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
);
case RowType.DynamicSecret:
return (
<SecretOverviewDynamicSecretRow
dynamicSecretName={row.name}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
/>
);
case RowType.Folder:
return (
<SecretOverviewFolderRow
folderName={row.name}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[row.name]}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, row.name)
}
environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
);
default:
return null;
}
})}
{!isOverviewLoading && visibleEnvs.length > 0 && (
<>
{folderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[folderName]}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, folderName)
}
environments={visibleEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
))}
{dynamicSecretNames.map((dynamicSecretName, index) => (
<SecretOverviewDynamicSecretRow
dynamicSecretName={dynamicSecretName}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={visibleEnvs}
key={`overview-${dynamicSecretName}-${index + 1}`}
/>
))}
{secKeys.map((key, index) => (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[key]}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
))}
</>
)}
</TBody>
<TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -883,10 +953,17 @@ export const SecretOverviewPage = () => {
</Tr>
</TFoot>
</Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && (
{!isOverviewLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="border-t border-solid border-t-mineshaft-600"
count={rows.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
@@ -13,7 +13,7 @@ import { twMerge } from "tailwind-merge";
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { SecretEditRow } from "./SecretEditRow";
@@ -53,6 +53,8 @@ export const SecretOverviewTableRow = ({
onSecretDelete,
isImportedSecretPresentInEnv,
getImportedSecretByKey,
// temporary until below todo is resolved
// eslint-disable-next-line @typescript-eslint/no-unused-vars
expandableColWidth,
onToggleSecretSelect,
isSelected
@@ -150,10 +152,11 @@ export const SecretOverviewTableRow = ({
}`}
>
<div
className="ml-2 p-2"
style={{
width: `calc(${expandableColWidth}px - 1rem)`
}}
className="ml-2 w-[99%] p-2"
// TODO: scott expandableColWidth sometimes 0 due to parent ref not mounting, opting for relative width until resolved
// style={{
// width: `calc(${expandableColWidth} - 1rem)`
// }}
>
<SecretRenameRow
secretKey={secretKey}
@@ -0,0 +1,45 @@
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
folderCount?: number;
importCount?: number;
secretCount?: number;
dynamicSecretCount?: number;
};
export const SecretTableResourceCount = ({
folderCount = 0,
dynamicSecretCount = 0,
secretCount = 0,
importCount = 0
}: Props) => {
return (
<div className="flex items-center gap-2 divide-x divide-mineshaft-500 text-sm text-mineshaft-400">
{importCount > 0 && (
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className=" text-green-700" />
<span>{importCount}</span>
</div>
)}
{folderCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>{folderCount}</span>
</div>
)}
{dynamicSecretCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>{dynamicSecretCount}</span>
</div>
)}
{secretCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>{secretCount}</span>
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export { SecretTableResourceCount } from "./SecretTableResourceCount";
@@ -1,15 +1,16 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetWorkspaceById, useMigrateProjectToV3 } from "@app/hooks/api";
import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
@@ -28,6 +29,7 @@ const formSchema = z.object({
export const SecretV2MigrationSection = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const);
const { currentWorkspace } = useWorkspace();
const queryClient = useQueryClient();
const { data: workspaceDetails, refetch } = useGetWorkspaceById(
// if v3 no need to fetch
currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "",
@@ -51,6 +53,7 @@ export const SecretV2MigrationSection = () => {
if (isProjectUpgraded && migrateProjectToV3.data) {
createNotification({ type: "success", text: "Project upgrade completed successfully" });
migrateProjectToV3.reset();
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
}
}, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]);
@@ -78,7 +81,7 @@ export const SecretV2MigrationSection = () => {
const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mt-4 rounded-lg border border-primary-600 bg-mineshaft-900 p-4">
<div className="mt-4 flex max-w-2xl flex-col rounded-lg border border-primary/50 bg-primary/10 px-6 py-5">
{isUpgrading && (
<div className="absolute top-0 left-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80">
<Spinner size="lg" className="text-primary" />
@@ -88,18 +91,29 @@ export const SecretV2MigrationSection = () => {
</div>
</div>
)}
<p className="mb-2 text-lg font-semibold">Action Required</p>
<p className="mb-4 leading-7 text-gray-400">
Infisical secrets engine is now 10x faster and allows you to encrypt secrets with your own
KMS. Upgrade your project to receive these improvements.
<div className="mb-4 flex items-start gap-2">
<FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
<p className="text-xl font-semibold">
Upgrade your project
</p>
</div>
<p className="mx-1 mb-4 leading-7 text-mineshaft-300">
Your existing workflows to fetch secrets will continue to work. However, viewing secrets on the UI requires you to upgrade your project.
</p>
<p className="mx-1 mb-4 leading-7 text-mineshaft-300">
Upgrading your project enables the use of Infisical&apos;s new secrets engine, which is 10x faster and
allows you to encrypt secrets with your own KMS provider.
</p>
<p className="mx-1 mb-6 leading-7 text-mineshaft-300">
The upgrade takes only 1-2 minutes and will not cause any downtime.
</p>
<Button
onClick={() => handlePopUpOpen("migrationInfo")}
isDisabled={!isAdmin || isUpgrading}
color="mineshaft"
isLoading={migrateProjectToV3.isLoading}
className="w-full "
>
{ isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"}
{isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"}
</Button>
{didProjectUpgradeFailed && (
<p className="mt-2 text-sm leading-7 text-red-400">
@@ -66,12 +66,11 @@ export const ManagePlansTable = ({ billingCycle }: Props) => {
{subscription &&
!isTableDataLoading &&
tableData &&
tableData.rows.map(({ name, starter, team, pro, enterprise }) => {
tableData.rows.map(({ name, starter, pro, enterprise }) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">{displayCell(starter)}</Td>
<Td className="text-center">{displayCell(team)}</Td>
<Td className="text-center">{displayCell(pro)}</Td>
<Td className="text-center">{displayCell(enterprise)}</Td>
</Tr>
@@ -5,39 +5,40 @@ import { useBackfillSecretReference } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
export const BackfillSecretReferenceSecretion = () => {
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const backfillSecretReferences = useBackfillSecretReference();
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const backfillSecretReferences = useBackfillSecretReference();
if (!currentWorkspace) return null;
if (!currentWorkspace) return null;
const handleBackfill = async () => {
if (backfillSecretReferences.isLoading) return;
try {
await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" });
createNotification({ text: "Successfully re-indexed secret references", type: "success" });
} catch {
createNotification({ text: "Failed to re-index secret references", type: "error" });
}
};
const handleBackfill = async () => {
if (backfillSecretReferences.isLoading) return;
try {
await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" });
createNotification({ text: "Successfully re-indexed secret references", type: "success" });
} catch {
createNotification({ text: "Failed to re-index secret references", type: "error" });
}
};
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Index Secret References</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This will index all secret references, enabling integrations to be triggered when their values change going forward.
</p>
<Button
variant="outline_bg"
isLoading={backfillSecretReferences.isLoading}
onClick={handleBackfill}
isDisabled={!isAdmin}
>
Index Secret References
</Button>
</div>
);
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Index Secret References</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This will index all secret references, enabling integrations to be triggered when their
values change going forward. This happens automatically when secrets are created or updated.
</p>
<Button
variant="outline_bg"
isLoading={backfillSecretReferences.isLoading}
onClick={handleBackfill}
isDisabled={!isAdmin}
>
Index Secret References
</Button>
</div>
);
};
@@ -5,6 +5,7 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api";
const slackFormSchema = z.object({
@@ -65,6 +66,8 @@ export const IntegrationPanel = () => {
const { data: adminSlackConfig } = useGetAdminSlackConfig();
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle();
const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle();
useEffect(() => {
if (adminSlackConfig) {
@@ -120,6 +123,9 @@ export const IntegrationPanel = () => {
<Input
{...field}
value={field.value || ""}
type={isSlackClientIdFocused ? "text" : "password"}
onFocus={() => setIsSlackClientIdFocused.on()}
onBlur={() => setIsSlackClientIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
@@ -138,6 +144,9 @@ export const IntegrationPanel = () => {
<Input
{...field}
value={field.value || ""}
type={isSlackClientSecretFocused ? "text" : "password"}
onFocus={() => setIsSlackClientSecretFocused.on()}
onBlur={() => setIsSlackClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>