Merge remote-tracking branch 'origin/main' into feat/resource-metadata

This commit is contained in:
Sheen Capadngan
2024-12-20 14:23:14 +08:00
231 changed files with 11454 additions and 167 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2";
import { isInfisicalCloud } from "@app/helpers/platform";
enum Region {
US = "us",
@@ -79,10 +80,7 @@ export const RegionSelect = () => {
};
const shouldDisplay =
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://us.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com") ||
window.location.origin.includes("http://localhost:8080");
isInfisicalCloud() || window.location.origin.includes("http://localhost:8080");
// only display region select for cloud
if (!shouldDisplay) return null;
@@ -44,7 +44,7 @@ export const FormLabel = ({
)}
{tooltipText && (
<Tooltip content={tooltipText} className={tooltipClassName}>
<FontAwesomeIcon icon={faQuestionCircle} size="1x" className="ml-2" />
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
)}
</Label.Root>
@@ -23,7 +23,8 @@ export enum OrgPermissionSubjects {
Kms = "kms",
AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs",
ProjectTemplates = "project-templates"
ProjectTemplates = "project-templates",
AppConnections = "app-connections"
}
export enum OrgPermissionAdminConsoleAction {
@@ -47,6 +48,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates];
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionActions, OrgPermissionSubjects.AppConnections];
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
@@ -85,6 +85,9 @@ export enum ProjectPermissionSub {
CertificateAuthorities = "certificate-authorities",
Certificates = "certificates",
CertificateTemplates = "certificate-templates",
SshCertificateAuthorities = "ssh-certificate-authorities",
SshCertificateTemplates = "ssh-certificate-templates",
SshCertificates = "ssh-certificates",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -165,6 +168,9 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
+29
View File
@@ -0,0 +1,29 @@
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AwsConnectionMethod,
GitHubConnectionMethod,
TAppConnection
} from "@app/hooks/api/appConnections/types";
export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: string }> = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
[AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
switch (method) {
case GitHubConnectionMethod.App:
return { name: "GitHub App", icon: faGithub };
case GitHubConnectionMethod.OAuth:
return { name: "OAuth", icon: faPassport };
case AwsConnectionMethod.AccessKey:
return { name: "Access Key", icon: faKey };
case AwsConnectionMethod.AssumeRole:
return { name: "Assume Role", icon: faUser };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
};
+4
View File
@@ -0,0 +1,4 @@
export const isInfisicalCloud = () =>
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://us.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com");
+7 -2
View File
@@ -69,14 +69,19 @@ export const getProjectHomePage = (workspace: Workspace) => {
return `/${workspace.type}/${workspace.id}/certificates`;
}
return `/${workspace.type}/${workspace.id}/kms`;
if (workspace.type === ProjectType.KMS) {
return `/${workspace.type}/${workspace.id}/kms`;
}
return `/${workspace.type}/${workspace.id}/ssh`;
};
export const getProjectTitle = (type: ProjectType) => {
const titleConvert = {
[ProjectType.SecretManager]: "Secret Management",
[ProjectType.KMS]: "Key Management",
[ProjectType.CertificateManager]: "Cert Management"
[ProjectType.CertificateManager]: "Cert Management",
[ProjectType.SSH]: "SSH"
};
return titleConvert[type];
};
@@ -0,0 +1,4 @@
export enum AppConnection {
AWS = "aws",
GitHub = "github"
}
@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";
@@ -0,0 +1,58 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections/queries";
import {
TAppConnectionResponse,
TCreateAppConnectionDTO,
TDeleteAppConnectionDTO,
TUpdateAppConnectionDTO
} from "@app/hooks/api/appConnections/types";
export const useCreateAppConnection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ app, ...params }: TCreateAppConnectionDTO) => {
const { data } = await apiRequest.post<TAppConnectionResponse>(
`/api/v1/app-connections/${app}`,
params
);
return data.appConnection;
},
onSuccess: () => queryClient.invalidateQueries(appConnectionKeys.list())
});
};
export const useUpdateAppConnection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ connectionId, app, ...params }: TUpdateAppConnectionDTO) => {
const { data } = await apiRequest.patch<TAppConnectionResponse>(
`/api/v1/app-connections/${app}/${connectionId}`,
params
);
return data.appConnection;
},
onSuccess: (_, { connectionId, app }) => {
queryClient.invalidateQueries(appConnectionKeys.list());
queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId));
}
});
};
export const useDeleteAppConnection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ connectionId, app }: TDeleteAppConnectionDTO) => {
const { data } = await apiRequest.delete(`/api/v1/app-connections/${app}/${connectionId}`);
return data;
},
onSuccess: (_, { connectionId, app }) => {
queryClient.invalidateQueries(appConnectionKeys.list());
queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId));
}
});
};
@@ -0,0 +1,136 @@
import { useMemo } from "react";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
TAppConnection,
TAppConnectionMap,
TAppConnectionOptions,
TGetAppConnection,
TListAppConnections
} from "@app/hooks/api/appConnections/types";
import {
TAppConnectionOption,
TAppConnectionOptionMap
} from "@app/hooks/api/appConnections/types/app-options";
export const appConnectionKeys = {
all: ["app-connection"] as const,
options: () => [...appConnectionKeys.all, "options"] as const,
list: () => [...appConnectionKeys.all, "list"] as const,
listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app],
byId: (app: AppConnection, templateId: string) =>
[...appConnectionKeys.all, app, "by-id", templateId] as const
};
export const useAppConnectionOptions = (
options?: Omit<
UseQueryOptions<
TAppConnectionOption[],
unknown,
TAppConnectionOption[],
ReturnType<typeof appConnectionKeys.options>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: appConnectionKeys.options(),
queryFn: async () => {
const { data } = await apiRequest.get<TAppConnectionOptions>(
"/api/v1/app-connections/options"
);
return data.appConnectionOptions;
},
...options
});
};
export const useGetAppConnectionOption = <T extends AppConnection>(app: T) => {
const { data: options = [], isLoading } = useAppConnectionOptions();
return useMemo(
() => ({
option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {},
isLoading
}),
[options, app]
);
};
export const useListAppConnections = (
options?: Omit<
UseQueryOptions<
TAppConnection[],
unknown,
TAppConnection[],
ReturnType<typeof appConnectionKeys.list>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: appConnectionKeys.list(),
queryFn: async () => {
const { data } = await apiRequest.get<TListAppConnections<TAppConnection>>(
"/api/v1/app-connections"
);
return data.appConnections;
},
...options
});
};
export const useListAppConnectionsByApp = <T extends AppConnection>(
app: T,
options?: Omit<
UseQueryOptions<
TAppConnectionMap[T][],
unknown,
TAppConnectionMap[T][],
ReturnType<typeof appConnectionKeys.listByApp>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: appConnectionKeys.listByApp(app),
queryFn: async () => {
const { data } = await apiRequest.get<TListAppConnections<TAppConnectionMap[T]>>(
`/api/v1/app-connections/${app}`
);
return data.appConnections;
},
...options
});
};
export const useGetAppConnectionById = <T extends AppConnection>(
app: T,
connectionId: string,
options?: Omit<
UseQueryOptions<
TAppConnectionMap[T],
unknown,
TAppConnectionMap[T],
ReturnType<typeof appConnectionKeys.byId>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: appConnectionKeys.byId(app, connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TGetAppConnection<TAppConnectionMap[T]>>(
`/api/v1/app-connections/${app}/${connectionId}`
);
return data.appConnection;
},
...options
});
};
@@ -0,0 +1,24 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
export type TAppConnectionOptionBase = {
name: string;
methods: string[];
};
export type TAwsConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.AWS;
accessKeyId?: string;
};
export type TGitHubConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.GitHub;
oauthClientId?: string;
appClientSlug?: string;
};
export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
[AppConnection.GitHub]: TGitHubConnectionOption;
};
@@ -0,0 +1,23 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum AwsConnectionMethod {
AssumeRole = "assume-role",
AccessKey = "access-key"
}
export type TAwsConnection = TRootAppConnection & { app: AppConnection.AWS } & (
| {
method: AwsConnectionMethod.AccessKey;
credentials: {
accessKeyId: string;
secretAccessKey: string;
};
}
| {
method: AwsConnectionMethod.AssumeRole;
credentials: {
roleArn: string;
};
}
);
@@ -0,0 +1,23 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum GitHubConnectionMethod {
App = "github-app",
OAuth = "oauth"
}
export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & (
| {
method: GitHubConnectionMethod.OAuth;
credentials: {
code: string;
};
}
| {
method: GitHubConnectionMethod.App;
credentials: {
code: string;
installationId: string;
};
}
);
@@ -0,0 +1,36 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options";
import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection";
import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection";
export * from "./aws-connection";
export * from "./github-connection";
export type TAppConnection = TAwsConnection | TGitHubConnection;
export type TListAppConnections<T extends TAppConnection> = { appConnections: T[] };
export type TGetAppConnection<T extends TAppConnection> = { appConnection: T };
export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] };
export type TAppConnectionResponse = { appConnection: TAppConnection };
export type TCreateAppConnectionDTO = Pick<
TAppConnection,
"name" | "credentials" | "method" | "app" | "description"
>;
export type TUpdateAppConnectionDTO = Partial<
Pick<TAppConnection, "name" | "credentials" | "description">
> & {
connectionId: string;
app: AppConnection;
};
export type TDeleteAppConnectionDTO = {
app: AppConnection;
connectionId: string;
};
export type TAppConnectionMap = {
[AppConnection.AWS]: TAwsConnection;
[AppConnection.GitHub]: TGitHubConnection;
};
@@ -0,0 +1,9 @@
export type TRootAppConnection = {
id: string;
name: string;
description?: string | null;
version: number;
orgId: string;
createdAt: string;
updatedAt: string;
};
+4 -1
View File
@@ -1,3 +1,6 @@
import { SshCaStatus } from "@app/hooks/api/ssh-ca";
import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates";
import { CaStatus, CaType } from "./enums";
export const caTypeToNameMap: { [K in CaType]: string } = {
@@ -11,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = {
[CaStatus.PENDING_CERTIFICATE]: "Pending Certificate"
};
export const getCaStatusBadgeVariant = (status: CaStatus) => {
export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => {
switch (status) {
case CaStatus.ACTIVE:
return "success";
+2
View File
@@ -38,6 +38,8 @@ export * from "./secretSharing";
export * from "./secretSnapshots";
export * from "./serverDetails";
export * from "./serviceTokens";
export * from "./ssh-ca";
export * from "./sshCertificateTemplates";
export * from "./ssoConfig";
export * from "./subscriptions";
export * from "./tags";
+2 -2
View File
@@ -56,7 +56,7 @@ export type TGetUserProjectPermissionDTO = {
export type TCreateOrgRoleDTO = {
orgId: string;
name: string;
description?: string;
description?: string | null;
slug: string;
permissions: TPermission[];
};
@@ -74,7 +74,7 @@ export type TDeleteOrgRoleDTO = {
export type TCreateProjectRoleDTO = {
projectId: string;
name: string;
description?: string;
description?: string | null;
slug: string;
permissions: TProjectPermission[];
};
@@ -0,0 +1,14 @@
export enum SshCaStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export enum SshCertType {
USER = "user",
HOST = "host"
}
export const sshCertTypeToNameMap: { [K in SshCertType]: string } = {
[SshCertType.USER]: "User",
[SshCertType.HOST]: "Host"
};
+9
View File
@@ -0,0 +1,9 @@
export { SshCaStatus } from "./constants";
export {
useCreateSshCa,
useDeleteSshCa,
useIssueSshCreds,
useSignSshKey,
useUpdateSshCa
} from "./mutations";
export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries";
@@ -0,0 +1,97 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
import {
TCreateSshCaDTO,
TDeleteSshCaDTO,
TIssueSshCredsDTO,
TIssueSshCredsResponse,
TSignSshKeyDTO,
TSignSshKeyResponse,
TSshCertificateAuthority,
TUpdateSshCaDTO
} from "./types";
export const sshCaKeys = {
getSshCaById: (caId: string) => [{ caId }, "ssh-ca"]
};
export const useCreateSshCa = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateAuthority, {}, TCreateSshCaDTO>({
mutationFn: async (body) => {
const {
data: { ca }
} = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body);
return ca;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId));
}
});
};
export const useUpdateSshCa = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateAuthority, {}, TUpdateSshCaDTO>({
mutationFn: async ({ caId, ...body }) => {
const {
data: { ca }
} = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body);
return ca;
},
onSuccess: ({ projectId }, { caId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId));
queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId));
}
});
};
export const useDeleteSshCa = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateAuthority, {}, TDeleteSshCaDTO>({
mutationFn: async ({ caId }) => {
const {
data: { ca }
} = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`);
return ca;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId));
}
});
};
export const useSignSshKey = () => {
const queryClient = useQueryClient();
return useMutation<TSignSshKeyResponse, {}, TSignSshKeyDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TSignSshKeyResponse>(
"/api/v1/ssh/certificates/sign",
body
);
return data;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId));
}
});
};
export const useIssueSshCreds = () => {
const queryClient = useQueryClient();
return useMutation<TIssueSshCredsResponse, {}, TIssueSshCredsDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TIssueSshCredsResponse>(
"/api/v1/ssh/certificates/issue",
body
);
return data;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId));
}
});
};
+37
View File
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
import { TSshCertificateAuthority } from "./types";
export const sshCaKeys = {
getSshCaById: (caId: string) => [{ caId }, "ssh-ca"],
getSshCaCertTemplates: (caId: string) => [{ caId }, "ssh-ca-cert-templates"]
};
export const useGetSshCaById = (caId: string) => {
return useQuery({
queryKey: sshCaKeys.getSshCaById(caId),
queryFn: async () => {
const {
data: { ca }
} = await apiRequest.get<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`);
return ca;
},
enabled: Boolean(caId)
});
};
export const useGetSshCaCertTemplates = (caId: string) => {
return useQuery({
queryKey: sshCaKeys.getSshCaCertTemplates(caId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateTemplates: TSshCertificateTemplate[];
}>(`/api/v1/ssh/ca/${caId}/certificate-templates`);
return data;
},
enabled: Boolean(caId)
});
};
+74
View File
@@ -0,0 +1,74 @@
import { CertKeyAlgorithm } from "../certificates/enums";
import { SshCaStatus, SshCertType } from "./constants";
export type TSshCertificate = {
id: string;
sshCaId: string;
sshCertificateTemplateId: string;
serialNumber: string;
certType: SshCertType;
principals: string[];
keyId: string;
notBefore: string;
notAfter: string;
};
export type TSshCertificateAuthority = {
id: string;
projectId: string;
status: SshCaStatus;
friendlyName: string;
keyAlgorithm: CertKeyAlgorithm;
createdAt: string;
updatedAt: string;
publicKey: string;
};
export type TCreateSshCaDTO = {
projectId: string;
friendlyName?: string;
keyAlgorithm: CertKeyAlgorithm;
};
export type TUpdateSshCaDTO = {
caId: string;
friendlyName?: string;
status?: SshCaStatus;
};
export type TDeleteSshCaDTO = {
caId: string;
};
export type TSignSshKeyDTO = {
projectId: string;
certificateTemplateId: string;
publicKey?: string;
certType: SshCertType;
principals: string[];
ttl?: string;
keyId?: string;
};
export type TSignSshKeyResponse = {
serialNumber: string;
signedKey: string;
};
export type TIssueSshCredsDTO = {
projectId: string;
certificateTemplateId: string;
keyAlgorithm: CertKeyAlgorithm;
certType: SshCertType;
principals: string[];
ttl?: string;
keyId?: string;
};
export type TIssueSshCredsResponse = {
serialNumber: string;
signedKey: string;
privateKey: string;
publicKey: string;
keyAlgorithm: CertKeyAlgorithm;
};
@@ -0,0 +1,7 @@
export {
useCreateSshCertTemplate,
useDeleteSshCertTemplate,
useUpdateSshCertTemplate
} from "./mutations";
export { useGetSshCertTemplate } from "./queries";
export * from "./types";
@@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { sshCaKeys } from "../ssh-ca/queries";
import {
TCreateSshCertificateTemplateDTO,
TDeleteSshCertificateTemplateDTO,
TSshCertificateTemplate,
TUpdateSshCertificateTemplateDTO
} from "./types";
export const useCreateSshCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateTemplate, {}, TCreateSshCertificateTemplateDTO>({
mutationFn: async (data) => {
const { data: certificateTemplate } = await apiRequest.post<TSshCertificateTemplate>(
"/api/v1/ssh/certificate-templates",
data
);
return certificateTemplate;
},
onSuccess: ({ sshCaId }) => {
queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId));
}
});
};
export const useUpdateSshCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateTemplate, {}, TUpdateSshCertificateTemplateDTO>({
mutationFn: async (data) => {
const { data: certificateTemplate } = await apiRequest.patch<TSshCertificateTemplate>(
`/api/v1/ssh/certificate-templates/${data.id}`,
data
);
return certificateTemplate;
},
onSuccess: ({ sshCaId }) => {
queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId));
}
});
};
export const useDeleteSshCertTemplate = () => {
const queryClient = useQueryClient();
return useMutation<TSshCertificateTemplate, {}, TDeleteSshCertificateTemplateDTO>({
mutationFn: async (data) => {
const { data: certificateTemplate } = await apiRequest.delete<TSshCertificateTemplate>(
`/api/v1/ssh/certificate-templates/${data.id}`
);
return certificateTemplate;
},
onSuccess: ({ sshCaId }) => {
queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId));
}
});
};
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSshCertificateTemplate } from "./types";
export const certTemplateKeys = {
getSshCertTemplateById: (id: string) => [{ id }, "ssh-cert-template"]
};
export const useGetSshCertTemplate = (id: string) => {
return useQuery({
queryKey: certTemplateKeys.getSshCertTemplateById(id),
queryFn: async () => {
const { data: certificateTemplate } = await apiRequest.get<TSshCertificateTemplate>(
`/api/v1/ssh/certificate-templates/${id}`
);
return certificateTemplate;
},
enabled: Boolean(id)
});
};
@@ -0,0 +1,47 @@
export enum SshCertTemplateStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export type TSshCertificateTemplate = {
id: string;
sshCaId: string;
status: SshCertTemplateStatus;
name: string;
ttl: string;
maxTTL: string;
allowedUsers: string[];
allowedHosts: string[];
allowUserCertificates: boolean;
allowHostCertificates: boolean;
allowCustomKeyIds: boolean;
};
export type TCreateSshCertificateTemplateDTO = {
sshCaId: string;
name: string;
ttl: string;
maxTTL: string;
allowedUsers: string[];
allowedHosts: string[];
allowUserCertificates: boolean;
allowHostCertificates: boolean;
allowCustomKeyIds: boolean;
};
export type TUpdateSshCertificateTemplateDTO = {
id: string;
status?: SshCertTemplateStatus;
name?: string;
ttl?: string;
maxTTL?: string;
allowedUsers?: string[];
allowedHosts?: string[];
allowUserCertificates?: boolean;
allowHostCertificates?: boolean;
allowCustomKeyIds?: boolean;
};
export type TDeleteSshCertificateTemplateDTO = {
id: string;
};
@@ -45,4 +45,5 @@ export type SubscriptionPlan = {
pkiEst: boolean;
enforceMfa: boolean;
projectTemplates: boolean;
appConnections: boolean; // TODO: remove once released
};
@@ -32,6 +32,9 @@ export {
useListWorkspaceGroups,
useListWorkspacePkiAlerts,
useListWorkspacePkiCollections,
useListWorkspaceSshCas,
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,
useNameWorkspaceSecrets,
useToggleAutoCapitalization,
useUpdateIdentityWorkspaceRole,
@@ -15,6 +15,8 @@ import { TIntegration } from "../integrations/types";
import { TPkiAlert } from "../pkiAlerts/types";
import { TPkiCollection } from "../pkiCollections/types";
import { EncryptedSecret } from "../secrets/types";
import { TSshCertificate, TSshCertificateAuthority } from "../ssh-ca/types";
import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
import { userKeys } from "../users/query-keys";
import { TWorkspaceUser } from "../users/types";
import { ProjectSlackConfig } from "../workflowIntegrations/types";
@@ -731,6 +733,67 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac
});
};
export const useListWorkspaceSshCertificates = ({
offset,
limit,
projectId
}: {
offset: number;
limit: number;
projectId: string;
}) => {
return useQuery({
queryKey: workspaceKeys.specificWorkspaceSshCertificates({
offset,
limit,
projectId
}),
queryFn: async () => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit)
});
const { data } = await apiRequest.get<{
certificates: TSshCertificate[];
totalCount: number;
}>(`/api/v2/workspace/${projectId}/ssh-certificates`, {
params
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshCas = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCas(projectId),
queryFn: async () => {
const {
data: { cas }
} = await apiRequest.get<{ cas: Omit<TSshCertificateAuthority, "publicKey">[] }>(
`/api/v2/workspace/${projectId}/ssh-cas`
);
return cas;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>(
`/api/v2/workspace/${projectId}/ssh-certificate-templates`
);
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId),
@@ -53,5 +53,19 @@ export const workspaceKeys = {
getWorkspaceCertificateTemplates: (workspaceId: string) =>
[{ workspaceId }, "workspace-certificate-templates"] as const,
getWorkspaceSlackConfig: (workspaceId: string) =>
[{ workspaceId }, "workspace-slack-config"] as const
[{ workspaceId }, "workspace-slack-config"] as const,
getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const,
allWorkspaceSshCertificates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificates"] as const,
specificWorkspaceSshCertificates: ({
offset,
limit,
projectId
}: {
offset: number;
limit: number;
projectId: string;
}) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const,
getWorkspaceSshCertificateTemplates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificate-templates"] as const
};
+2 -1
View File
@@ -11,7 +11,8 @@ export enum ProjectVersion {
export enum ProjectType {
SecretManager = "secret-manager",
CertificateManager = "cert-manager",
KMS = "kms"
KMS = "kms",
SSH = "ssh"
}
export enum ProjectUserMembershipTemporaryMode {
+15 -1
View File
@@ -327,6 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div>
)}
{!router.asPath.includes("org") &&
!router.asPath.includes("app-connections") &&
(!router.asPath.includes("personal") && currentWorkspace ? (
<ProjectSelect />
) : (
@@ -339,7 +340,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
))}
<div className={`px-1 ${!router.asPath.includes("personal") ? "block" : "hidden"}`}>
<ProjectSidebarItem />
{router.pathname.startsWith("/org") && (
{(router.pathname.startsWith("/org") ||
router.pathname.startsWith("/app-connections")) && (
<Menu className="mt-4">
<Link
href={`/org/${currentOrg?.id}/${ProjectType.SecretManager}/overview`}
@@ -381,6 +383,18 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
<Link href={`/org/${currentOrg?.id}/ssh/overview`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/org/${currentWorkspace?.id}/ssh/overview`
}
icon="system-regular-126-verified-hover-verified"
>
SSH
</MenuItem>
</a>
</Link>
<Link href={`/org/${currentOrg?.id}/members`} passHref>
<a>
<MenuItem
@@ -24,7 +24,8 @@ export const ProjectSidebarItem = () => {
if (
!currentWorkspace ||
router.asPath.startsWith("personal") ||
router.asPath.startsWith("integrations")
router.asPath.startsWith("integrations") ||
router.asPath.startsWith("/app-connections")
) {
return <div />;
}
@@ -32,6 +33,7 @@ export const ProjectSidebarItem = () => {
const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager;
const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager;
const isCmek = currentWorkspace?.type === ProjectType.KMS;
const isSsh = currentWorkspace?.type === ProjectType.SSH;
return (
<Menu>
@@ -82,6 +84,18 @@ export const ProjectSidebarItem = () => {
</a>
</Link>
)}
{isSsh && (
<Link href={`/${ProjectType.SSH}/${currentWorkspace?.id}/ssh`} passHref>
<a>
<MenuItem
isSelected={router.asPath === `/${ProjectType.SSH}/${currentWorkspace?.id}/ssh`}
icon="system-outline-90-lock-closed"
>
Overview
</MenuItem>
</a>
</Link>
)}
<Link href={`/${currentWorkspace.type}/${currentWorkspace?.id}/members`} passHref>
<a>
<MenuItem
+1
View File
@@ -0,0 +1 @@
export type DiscriminativePick<T, K extends keyof T> = T extends unknown ? Pick<T, K> : never;
@@ -0,0 +1,134 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import queryString from "query-string";
import { createNotification } from "@app/components/notifications";
import { ContentLoader } from "@app/components/v2";
import {
GitHubConnectionMethod,
TAppConnection,
TGitHubConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type FormData = Pick<TGitHubConnection, "name" | "method" | "description"> & {
returnUrl?: string;
connectionId?: string;
};
export default function GitHubOAuthCallbackPage() {
const router = useRouter();
const updateAppConnection = useUpdateAppConnection();
const createAppConnection = useCreateAppConnection();
// eslint-disable-next-line @typescript-eslint/naming-convention
const {
code,
state,
installation_id: installationId
} = queryString.parse(router.asPath.split("?")[1]);
useEffect(() => {
(async () => {
let formData: FormData;
try {
formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData;
} catch (e) {
createNotification({
type: "error",
text: "Invalid form state, redirecting..."
});
router.push(window.location.origin);
return;
}
// validate state
if (state !== localStorage.getItem("latestCSRFToken")) {
createNotification({
type: "error",
text: "Invalid state, redirecting..."
});
router.push(window.location.origin);
return;
}
localStorage.removeItem("githubConnectionFormData");
localStorage.removeItem("latestCSRFToken");
const { connectionId, name, description, returnUrl } = formData;
let appConnection: TAppConnection;
try {
if (connectionId) {
appConnection = await updateAppConnection.mutateAsync({
app: AppConnection.GitHub,
...(installationId
? {
connectionId,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
connectionId,
credentials: {
code: code as string
}
})
});
} else {
appConnection = await createAppConnection.mutateAsync({
app: AppConnection.GitHub,
name,
description,
...(installationId
? {
method: GitHubConnectionMethod.App,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
method: GitHubConnectionMethod.OAuth,
credentials: {
code: code as string
}
})
});
}
} catch (e: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`,
text: e.message,
type: "error"
});
router.push(
returnUrl ??
`/org/${localStorage.getItem("orgData.id")}/settings?selectedTab=app-connections`
);
return;
}
createNotification({
text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`,
type: "success"
});
router.push(returnUrl ?? `/org/${appConnection.orgId}/settings?selectedTab=app-connections`);
})();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader />
</div>
);
}
GitHubOAuthCallbackPage.requireAuth = true;
@@ -13,6 +13,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
import {
Button,
@@ -36,12 +37,26 @@ const vercelEnvironments = [
{ name: "Production", slug: "production" }
];
const initialSyncBehaviors = [
{
label: "No Import - Overwrite all values in Vercel",
value: IntegrationSyncBehavior.OVERWRITE_TARGET
},
{
label: "Import - Prefer values from Infisical",
value: IntegrationSyncBehavior.PREFER_SOURCE
}
];
export default function VercelCreateIntegrationPage() {
const router = useRouter();
const { mutateAsync } = useCreateIntegration();
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [secretPath, setSecretPath] = useState("/");
const [initialSyncBehavior, setInitialSyncBehavior] = useState<IntegrationSyncBehavior>(
IntegrationSyncBehavior.PREFER_SOURCE
);
const [targetAppId, setTargetAppId] = useState("");
const [targetEnvironment, setTargetEnvironment] = useState("");
const [targetBranch, setTargetBranch] = useState("");
@@ -104,7 +119,10 @@ export default function VercelCreateIntegrationPage() {
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment,
path,
secretPath
secretPath,
metadata: {
initialSyncBehavior
}
});
setIsLoading(false);
@@ -231,6 +249,21 @@ export default function VercelCreateIntegrationPage() {
</Select>
</FormControl>
)}
<FormControl label="Initial Sync Behavior" className="px-6">
<Select
value={initialSyncBehavior}
onValueChange={(val) => setInitialSyncBehavior(val as IntegrationSyncBehavior)}
className="w-full border border-mineshaft-500 text-sm"
>
{initialSyncBehaviors.map((syncBehavior) => (
<SelectItem value={syncBehavior.value} key={`sync-behavior-${syncBehavior.value}`}>
{syncBehavior.label}
</SelectItem>
))}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
@@ -62,7 +62,8 @@ enum ProjectOrderBy {
const formatTitle = (type: ProjectType) => {
if (type === ProjectType.SecretManager) return "Secret Management";
if (type === ProjectType.CertificateManager) return "Cert Management";
return "Key Management";
if (type === ProjectType.KMS) return "Key Management";
return "SSH";
};
const formatDescription = (type: ProjectType) => {
@@ -70,7 +71,9 @@ const formatDescription = (type: ProjectType) => {
return "Securely store, manage, and rotate various application secrets, such as database credentials, API keys, etc.";
if (type === ProjectType.CertificateManager)
return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices.";
return "Centralize the management of keys for cryptographic operations, such as encryption and decryption.";
if (type === ProjectType.KMS)
return "Centralize the management of keys for cryptographic operations, such as encryption and decryption.";
return "Generate SSH credentials to provide secure and centralized SSH access control for your infrastructure.";
};
type Props = {
@@ -0,0 +1,9 @@
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProductOverview } from "../secret-manager/overview";
const SshManagerOverviewPage = () => <ProductOverview type={ProjectType.SSH} />;
Object.assign(SshManagerOverviewPage, { requireAuth: true });
export default SshManagerOverviewPage;
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { IPAllowlistPage } from "@app/views/Project/IPAllowListPage";
const ProjectAllowlist = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<IPAllowlistPage />
</>
);
};
export default ProjectAllowlist;
ProjectAllowlist.requireAuth = true;
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Head from "next/head";
import { SshCaPage } from "@app/views/Project/SshCaPage";
export default function SshCa() {
return (
<>
<Head>
<title>SSH Certificate Authority</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<SshCaPage />
</>
);
}
SshCa.requireAuth = true;
@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { IdentityDetailsPage } from "@app/views/Project/IdentityDetailsPage";
export default function ProjectIdentityDetailsPage() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<IdentityDetailsPage />
</>
);
}
ProjectIdentityDetailsPage.requireAuth = true;
@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { MemberDetailsPage } from "@app/views/Project/MemberDetailsPage";
export default function Page() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<MemberDetailsPage />
</>
);
}
Page.requireAuth = true;
@@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { MembersPage } from "@app/views/Project/MembersPage";
export default function WorkspaceMemberSettings() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<MembersPage />
</>
);
}
WorkspaceMemberSettings.requireAuth = true;
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { RolePage } from "@app/views/Project/RolePage";
export default function Role() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: "Project Settings" })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<RolePage />
</>
);
}
Role.requireAuth = true;
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { ProjectSettingsPage } from "@app/views/Settings/ProjectSettingsPage";
const ProjectSettings = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<ProjectSettingsPage />
</>
);
};
export default ProjectSettings;
ProjectSettings.requireAuth = true;
+23
View File
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SshPage } from "@app/views/Project/SshPage";
const Ssh = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: "Certificates" })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<SshPage />
</div>
);
};
export default Ssh;
Ssh.requireAuth = true;
@@ -21,7 +21,7 @@ const adminConsolePermissionSchmea = z
export const formSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
description: z.string().trim().nullish(),
slug: z
.string()
.trim()
@@ -49,7 +49,8 @@ export const formSchema = z.object({
identity: generalPermissionSchema,
"organization-admin-console": adminConsolePermissionSchmea,
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
[OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema
[OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema,
[OrgPermissionSubjects.AppConnections]: generalPermissionSchema
})
.optional()
});
@@ -71,12 +71,6 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({ name, description, slug }: FormData) => {
try {
console.log("onFormSubmit args: ", {
name,
description,
slug
});
if (!orgId) return;
if (role) {
@@ -97,7 +97,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const totalActions = getPermissionList(formName).length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
@@ -69,7 +69,8 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "External KMS",
formName: OrgPermissionSubjects.Kms
},
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates },
{ title: "App Connections", formName: OrgPermissionSubjects.AppConnections }
] as const;
type Props = {
@@ -17,7 +17,7 @@ import {
// DatePicker
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaType, useCreateCa, useGetCaById,useUpdateCa } from "@app/hooks/api/ca";
import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -72,7 +72,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
// const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || "");
const { mutateAsync: createMutateAsync } = useCreateCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
@@ -151,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
if (ca) {
// update
await updateMutateAsync({
@@ -1,10 +1,5 @@
import { useRouter } from "next/router";
import {
faBan,
faCertificate,
faEllipsis,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
@@ -1,7 +1,3 @@
/**
* TODO (dangtony98): Reevaluate if this component should be in main
* CertificateTab or under CA page in the future.
*/
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -18,7 +14,7 @@ import { CertificateTemplatesTable } from "./CertificateTemplatesTable";
type Props = {
caId: string;
}
};
export const CertificateTemplatesSection = ({ caId }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -73,7 +73,7 @@ const ConditionSchema = z
export const projectRoleFormSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
description: z.string().trim().nullish(),
slug: z
.string()
.trim()
@@ -126,6 +126,11 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[]
),
[ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
@@ -210,6 +215,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.PkiAlerts,
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.CertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation,
@@ -596,6 +604,33 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificateAuthorities]: {
title: "SSH Certificate Authorities",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificates]: {
title: "SSH Certificates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificateTemplates]: {
title: "SSH Certificate Templates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
@@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshCaModal } from "../SshPage/components/SshCaModal";
import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components";
export const SshCaPage = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const router = useRouter();
const caId = router.query.caId as string;
const { data } = useGetSshCaById(caId);
const { mutateAsync: deleteSshCa } = useDeleteSshCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshCa",
"deleteSshCa"
] as const);
const onRemoveCaSubmit = async (caIdToDelete: string) => {
try {
if (!projectId) return;
await deleteSshCa({ caId: caIdToDelete });
await createNotification({
text: "Successfully deleted SSH CA",
type: "success"
});
handlePopUpClose("deleteSshCa");
router.push(`/project/${projectId}/ssh`);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH CA",
type: "error"
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<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(`/${ProjectType.SSH}/${projectId}/ssh`)}
className="mb-4"
>
SSH Certificate Authorities
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.friendlyName}</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">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteSshCa", {
caId: data.id
})
}
disabled={!isAllowed}
>
Delete SSH CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<SshCaDetailsSection caId={caId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full">
<SshCertificateTemplatesSection caId={caId} />
</div>
</div>
</div>
)}
<SshCaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshCa.isOpen}
title="Are you sure want to remove the SSH CA from the project?"
onChange={(isOpen) => handlePopUpToggle("deleteSshCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities }
);
@@ -0,0 +1,124 @@
import { faCheck, faCopy, faDownload, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetSshCaById } from "@app/hooks/api";
import { caStatusToNameMap } from "@app/hooks/api/ca/constants";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
caId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshCa"]>, data?: {}) => void;
};
export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const [downloadText, isDownloading, setDownloadText] = useTimedReset<string>({
initialState: "Save public key"
});
const { data: ca } = useGetSshCaById(caId);
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return ca ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH CA Details</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) => {
return (
<Tooltip content="Edit SSH CA">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshCa", {
caId: ca.id
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">SSH CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{ca.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Friendly Name</p>
<p className="text-sm text-mineshaft-300">{ca.friendlyName}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{caStatusToNameMap[ca.status]}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Key Algorithm</p>
<p className="text-sm text-mineshaft-300">{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Public Key</p>
<div className="group flex align-top">
<p className="flex-1 text-sm text-mineshaft-300">{ca.publicKey.substring(0, 20)}...</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={downloadText}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
setDownloadText("Saved");
downloadTxtFile("ssh_ca.pub", ca.publicKey);
}}
>
<FontAwesomeIcon icon={isDownloading ? faCheck : faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1,174 @@
import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { IconButton, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type Props = {
serialNumber: string;
signedKey: string;
privateKey?: string;
publicKey?: string;
};
export const SshCertificateContent = ({
serialNumber,
signedKey,
privateKey,
publicKey
}: Props) => {
const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset<string>(
{
initialState: "Copy to clipboard"
}
);
const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificatePk, isCopyingCertificatePk, setCopyTextCertificatePk] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{serialNumber}</p>
<Tooltip content={copyTextSerialNumber}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(serialNumber);
setCopyTextSerialNumber("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingSerialNumber ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
<div className="mb-4 flex items-center justify-between">
<h2>SSH Certificate / Signed Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificate}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(signedKey);
setCopyTextCertificate("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificate ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content="Download">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key-cert.pub", signedKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{signedKey}</p>
</div>
{privateKey && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Private Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setCopyTextCertificateSk("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificateSk ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key.pem", privateKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{privateKey}</p>
</div>
</>
)}
{publicKey && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Public Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificatePk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setCopyTextCertificatePk("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificatePk ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content={copyTextCertificatePk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key.pub", publicKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{publicKey}</p>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,373 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
SshCertTemplateStatus,
useGetSshCertTemplate,
useIssueSshCreds,
useListWorkspaceSshCertificateTemplates,
useSignSshKey
} from "@app/hooks/api";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { SshCertType } from "@app/hooks/api/ssh-ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { SshCertificateContent } from "./SshCertificateContent";
const schema = z.object({
templateId: z.string(),
publicKey: z.string().optional(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
]),
certType: z.nativeEnum(SshCertType),
principals: z.string(),
ttl: z
.string()
.trim()
.refine((val) => ms(val) > 0, "TTL must be a valid time string such as 2 days, 1d, 2h 1y, ...")
.optional(),
keyId: z.string().optional()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["sshCertificate"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCertificate"]>, state?: boolean) => void;
};
type TSshCertificateDetails = {
serialNumber: string;
signedKey: string;
privateKey?: string;
publicKey?: string;
};
enum SshCertificateOperation {
SIGN_SSH_KEY = "sign-ssh-key",
ISSUE_SSH_CREDS = "issue-ssh-creds"
}
export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const [operation, setOperation] = useState<SshCertificateOperation>(
SshCertificateOperation.SIGN_SSH_KEY
);
const [certificateDetails, setCertificateDetails] = useState<TSshCertificateDetails | null>(null);
const { mutateAsync: signSshKey } = useSignSshKey();
const { mutateAsync: issueSshCreds } = useIssueSshCreds();
const popUpData = popUp?.sshCertificate?.data as {
sshCaId: string;
templateId: string;
};
const { data: templatesData } = useListWorkspaceSshCertificateTemplates(projectId);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
setValue,
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
certType: SshCertType.USER
}
});
const templateId = watch("templateId");
const { data: templateData } = useGetSshCertTemplate(templateId);
useEffect(() => {
if (popUpData) {
setValue("templateId", popUpData.templateId);
} else if (templatesData && templatesData.certificateTemplates.length > 0) {
setValue("templateId", templatesData.certificateTemplates[0].id);
}
}, [popUpData]);
const onFormSubmit = async ({
keyAlgorithm,
certType,
publicKey: existingPublicKey,
principals,
ttl,
keyId
}: FormData) => {
try {
if (!templateData) return;
if (!projectId) return;
switch (operation) {
case SshCertificateOperation.SIGN_SSH_KEY: {
const { serialNumber, signedKey } = await signSshKey({
projectId,
certificateTemplateId: templateData.id,
publicKey: existingPublicKey,
certType,
principals: principals.split(",").map((user) => user.trim()),
ttl,
keyId
});
setCertificateDetails({
serialNumber,
signedKey
});
break;
}
case SshCertificateOperation.ISSUE_SSH_CREDS: {
const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({
projectId,
certificateTemplateId: templateData.id,
keyAlgorithm,
certType,
principals: principals.split(",").map((user) => user.trim()),
ttl,
keyId
});
setCertificateDetails({
serialNumber,
privateKey,
publicKey,
signedKey
});
break;
}
default: {
break;
}
}
reset();
createNotification({
text: "Successfully created SSH certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SSH certificate",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("sshCertificate", isOpen);
reset();
setCertificateDetails(null);
}}
>
<ModalContent title="Issue SSH Certificate">
{!certificateDetails ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="templateId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Template"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(popUpData?.sshCaId)}
>
{(templatesData?.certificateTemplates || [])
.filter((template) => template.status === SshCertTemplateStatus.ACTIVE)
.map(({ id, name }) => (
<SelectItem value={id} key={`ssh-cert-template-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<FormControl label="Operation">
<Select
defaultValue="issue-ssh-creds"
value={operation}
onValueChange={(e) => setOperation(e as SshCertificateOperation)}
className="w-full"
>
<SelectItem value="sign-ssh-key" key="sign-ssh-key">
Sign SSH Key
</SelectItem>
<SelectItem value="issue-ssh-creds" key="issue-ssh-creds">
Issue SSH Credentials
</SelectItem>
</Select>
</FormControl>
<Controller
control={control}
name="certType"
defaultValue={SshCertType.USER}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Type"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{templateData && templateData.allowUserCertificates && (
<SelectItem value={SshCertType.USER}>User</SelectItem>
)}
{templateData && templateData.allowHostCertificates && (
<SelectItem value={SshCertType.HOST}>Host</SelectItem>
)}
</Select>
</FormControl>
)}
/>
{operation === SshCertificateOperation.SIGN_SSH_KEY && (
<Controller
control={control}
name="publicKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="SSH Public Key"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="ssh-rsa AAA ..." />
</FormControl>
)}
/>
)}
{operation === SshCertificateOperation.ISSUE_SSH_CREDS && (
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{certKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="principals"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Principal(s)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="ec2-user" />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl label="TTL" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
{templateData && templateData.allowCustomKeyIds && (
<Controller
control={control}
name="keyId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Key ID" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="12345678" />
</FormControl>
)}
/>
)}
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCertificate", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<SshCertificateContent
serialNumber={certificateDetails.serialNumber}
signedKey={certificateDetails.signedKey}
publicKey={certificateDetails.publicKey}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,411 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import slugify from "@sindresorhus/slugify";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateSshCertTemplate,
useGetSshCaById,
useGetSshCertTemplate,
useListWorkspaceSshCas,
useUpdateSshCertTemplate
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
// Validates usernames or wildcard (*)
export const isValidUserPattern = (value: string): boolean => {
// Matches valid Linux usernames or a wildcard (*)
const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/;
return userRegex.test(value);
};
// Validates hostnames, wildcard domains, or IP addresses
export const isValidHostPattern = (value: string): boolean => {
// Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses
const hostRegex =
/^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/;
return hostRegex.test(value);
};
const schema = z
.object({
sshCaId: z.string(),
name: z
.string()
.trim()
.toLowerCase()
.min(1)
.max(36)
.refine((v) => slugify(v) === v, {
message: "Invalid name. Name can only contain alphanumeric characters and hyphens."
}),
ttl: z
.string()
.trim()
.refine(
(val) => ms(val) > 0,
"TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..."
)
.default("1h"),
maxTTL: z
.string()
.trim()
.refine(
(val) => ms(val) > 0,
"Max TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..."
)
.default("30d"),
allowedUsers: z.string().refine(
(val) => {
const trimmed = val.trim();
if (trimmed === "") return true;
const users = trimmed.split(",").map((u) => u.trim());
return users.every(isValidUserPattern);
},
{
message: "Invalid user pattern in allowedUsers"
}
),
allowedHosts: z.string().refine(
(val) => {
const trimmed = val.trim();
if (trimmed === "") return true;
const users = trimmed.split(",").map((u) => u.trim());
return users.every(isValidHostPattern);
},
{
message: "Invalid host pattern in allowedHosts"
}
),
allowUserCertificates: z.boolean().optional().default(false),
allowHostCertificates: z.boolean().optional().default(false),
allowCustomKeyIds: z.boolean().optional().default(false)
})
.refine((data) => ms(data.maxTTL) > ms(data.ttl), {
message: "Max TLL must be greater than TTL",
path: ["maxTTL"]
});
export type FormData = z.infer<typeof schema>;
type Props = {
sshCaId: string;
popUp: UsePopUpState<["sshCertificateTemplate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["sshCertificateTemplate"]>,
state?: boolean
) => void;
};
export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetSshCaById(sshCaId);
const { data: certTemplate } = useGetSshCertTemplate(
(popUp?.sshCertificateTemplate?.data as { id: string })?.id || ""
);
const { data: cas } = useListWorkspaceSshCas(currentWorkspace?.id || "");
const { mutateAsync: createSshCertTemplate } = useCreateSshCertTemplate();
const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {}
});
useEffect(() => {
if (certTemplate) {
reset({
sshCaId: certTemplate.sshCaId,
name: certTemplate.name,
ttl: certTemplate.ttl,
maxTTL: certTemplate.maxTTL,
allowedUsers: certTemplate.allowedUsers.join(", "),
allowedHosts: certTemplate.allowedHosts.join(", "),
allowUserCertificates: certTemplate.allowUserCertificates,
allowHostCertificates: certTemplate.allowHostCertificates,
allowCustomKeyIds: certTemplate.allowCustomKeyIds
});
} else {
reset({
sshCaId,
name: "",
ttl: "1h",
maxTTL: "30d",
allowedUsers: "",
allowedHosts: "",
allowUserCertificates: false,
allowHostCertificates: false,
allowCustomKeyIds: false
});
}
}, [certTemplate, ca]);
const onFormSubmit = async ({
name,
ttl,
maxTTL,
allowUserCertificates,
allowHostCertificates,
allowedUsers,
allowedHosts,
allowCustomKeyIds
}: FormData) => {
try {
if (certTemplate) {
await updateSshCertTemplate({
id: certTemplate.id,
name,
ttl,
maxTTL,
allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [],
allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [],
allowUserCertificates,
allowHostCertificates,
allowCustomKeyIds
});
createNotification({
text: "Successfully updated SSH certificate template",
type: "success"
});
} else {
await createSshCertTemplate({
sshCaId,
name,
ttl,
maxTTL,
allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [],
allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [],
allowUserCertificates,
allowHostCertificates,
allowCustomKeyIds
});
createNotification({
text: "Successfully created SSH certificate template",
type: "success"
});
}
reset();
handlePopUpToggle("sshCertificateTemplate", false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to save changes",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshCertificateTemplate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("sshCertificateTemplate", isOpen);
reset();
}}
>
<ModalContent
title={certTemplate ? "SSH Certificate Template" : "Create SSH Certificate Template"}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
{certTemplate && (
<FormControl label="SSH Certificate Template ID">
<Input value={certTemplate.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="SSH Template Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="administrator" />
</FormControl>
)}
/>
<Controller
control={control}
name="sshCaId"
defaultValue={sshCaId}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing SSH CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled
>
{(cas || []).map(({ id, friendlyName }) => (
<SelectItem value={id} key={`ssh-ca-${id}`}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="allowedUsers"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Users"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="ec2-user, developer, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="allowedHosts"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Hosts"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="*.compute.amazonaws.com, api.example.com, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Default TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="allowUserCertificates"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="allow-user-certificates"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">Allow User Certificates</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="allowHostCertificates"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="allow-host-certificates"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">Allow Host Certificates</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="allowCustomKeyIds"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="allow-custom-key-ids"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">Allow Custom Key IDs</p>
</Switch>
</FormControl>
);
}}
/>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Save
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCertificateTemplate", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,161 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
SshCertTemplateStatus,
useDeleteSshCertTemplate,
useUpdateSshCertTemplate
} from "@app/hooks/api";
import { SshCertificateModal } from "./SshCertificateModal";
import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal";
import { SshCertificateTemplatesTable } from "./SshCertificateTemplatesTable";
type Props = {
caId: string;
};
export const SshCertificateTemplatesSection = ({ caId }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshCertificateTemplate",
"sshCertificateTemplateStatus",
"sshCertificate",
"deleteSshCertificateTemplate",
"upgradePlan"
] as const);
const { mutateAsync: deleteSshCertTemplate } = useDeleteSshCertTemplate();
const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate();
const onRemoveSshCertificateTemplateSubmit = async (id: string) => {
try {
await deleteSshCertTemplate({
id
});
await createNotification({
text: "Successfully deleted SSH certificate template",
type: "success"
});
handlePopUpClose("deleteSshCertificateTemplate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH certificate template",
type: "error"
});
}
};
const onUpdateSshCaStatus = async ({
templateId,
status
}: {
templateId: string;
status: SshCertTemplateStatus;
}) => {
try {
await updateSshCertTemplate({ id: templateId, status });
await createNotification({
text: `Successfully ${
status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled"
} SSH certificate template`,
type: "success"
});
handlePopUpClose("sshCertificateTemplateStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${
status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled"
} SSH certificate template`,
type: "error"
});
}
};
return (
<div className="h-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">Certificate Templates</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshCertificateTemplates}
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => handlePopUpOpen("sshCertificateTemplate")}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<SshCertificateTemplatesTable handlePopUpOpen={handlePopUpOpen} sshCaId={caId} />
</div>
<SshCertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<SshCertificateTemplateModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
sshCaId={caId}
/>
<DeleteActionModal
isOpen={popUp.deleteSshCertificateTemplate.isOpen}
title={`Are you sure want to delete the SSH certificate template ${
(popUp?.deleteSshCertificateTemplate?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSshCertificateTemplate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshCertificateTemplateSubmit(
(popUp?.deleteSshCertificateTemplate?.data as { id: string })?.id
)
}
/>
<DeleteActionModal
isOpen={popUp.sshCertificateTemplateStatus.isOpen}
title={`Are you sure want to ${
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "enable"
: "disable"
} this certificate template?`}
subTitle={
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "This action will allow certificate issuance under this template again."
: "This action will prevent certificate issuance under this template."
}
onChange={(isOpen) => handlePopUpToggle("sshCertificateTemplateStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateSshCaStatus(
popUp?.sshCertificateTemplateStatus?.data as {
templateId: string;
status: SshCertTemplateStatus;
}
)
}
buttonText={
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "Enable"
: "Disable"
}
/>
</div>
);
};
@@ -0,0 +1,193 @@
import {
faBan,
faCertificate,
faEllipsis,
faFileAlt,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SshCertTemplateStatus, useGetSshCaCertTemplates } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
sshCaId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
[
"sshCertificateTemplate",
"sshCertificateTemplateStatus",
"sshCertificate",
"deleteSshCertificateTemplate",
"upgradePlan"
]
>,
data?: {
id?: string;
name?: string;
sshCaId?: string;
status?: SshCertTemplateStatus;
templateId?: string;
}
) => void;
};
export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props) => {
const { data, isLoading } = useGetSshCaCertTemplates(sshCaId);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="project-cas" />}
{!isLoading &&
data?.certificateTemplates.map((certificateTemplate) => {
return (
<Tr className="h-10" key={`certificate-${certificateTemplate.id}`}>
<Td>{certificateTemplate.name}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(certificateTemplate.status)}>
{caStatusToNameMap[certificateTemplate.status]}
</Badge>
</Td>
<Td className="flex justify-end">
<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">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshCertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshCertificateTemplateStatus", {
templateId: certificateTemplate.id,
status:
certificateTemplate.status === SshCertTemplateStatus.ACTIVE
? SshCertTemplateStatus.DISABLED
: SshCertTemplateStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${
certificateTemplate.status === SshCertTemplateStatus.ACTIVE
? "Disable"
: "Enable"
} Template`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshCertificateTemplates}
>
<DropdownMenuItem
onClick={() => {
handlePopUpOpen("sshCertificate", {
sshCaId,
templateId: certificateTemplate.id
});
}}
icon={
<FontAwesomeIcon icon={faCertificate} size="sm" className="mr-1" />
}
>
Issue Certificate
</DropdownMenuItem>
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshCertificateTemplates}
>
<DropdownMenuItem
onClick={() =>
handlePopUpOpen("sshCertificateTemplate", {
id: certificateTemplate.id
})
}
icon={<FontAwesomeIcon icon={faFileAlt} size="sm" className="mr-1" />}
>
Edit Template
</DropdownMenuItem>
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshCertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} size="sm" className="mr-1" />}
onClick={() =>
handlePopUpOpen("deleteSshCertificateTemplate", {
id: certificateTemplate.id,
name: certificateTemplate.name
})
}
>
Delete Template
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && !data?.certificateTemplates?.length && (
<EmptyState
title="No certificate templates have been created for this SSH CA"
icon={faFileAlt}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,3 @@
export { SshCaDetailsSection } from "./SshCaDetailsSection";
export { SshCertificateModal } from "./SshCertificateModal";
export { SshCertificateTemplatesSection } from "./SshCertificateTemplatesSection";
@@ -0,0 +1 @@
export { SshCaPage } from "./SshCaPage";
@@ -0,0 +1,53 @@
import { motion } from "framer-motion";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { SshCaSection, SshCertificatesSection } from "./components";
enum TabSections {
SshCa = "ssh-certificate-authorities",
SshCertificates = "ssh-certificates"
}
export const SshPage = withProjectPermission(
() => {
return (
<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">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">SSH</p>
<Tabs defaultValue={TabSections.SshCertificates}>
<TabList>
<Tab value={TabSections.SshCertificates}>SSH Certificates</Tab>
<Tab value={TabSections.SshCa}>Certificate Authorities</Tab>
</TabList>
<TabPanel value={TabSections.SshCertificates}>
<motion.div
key="panel-ssh-certificate-s"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<SshCertificatesSection />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.SshCa}>
<motion.div
key="panel-ssh-certificate-authorities"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<SshCaSection />
</motion.div>
</TabPanel>
</Tabs>
</div>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities }
);
@@ -0,0 +1,192 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshCa"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void;
};
const schema = z
.object({
friendlyName: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const router = useRouter();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || "");
const { mutateAsync: createMutateAsync } = useCreateSshCa();
const { mutateAsync: updateMutateAsync } = useUpdateSshCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
friendlyName: "",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
}
});
useEffect(() => {
if (ca) {
reset({
friendlyName: ca.friendlyName,
keyAlgorithm: ca.keyAlgorithm
});
} else {
reset({
friendlyName: "",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
});
}
}, [ca]);
const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => {
try {
if (!projectId) return;
if (ca) {
await updateMutateAsync({
caId: ca.id,
friendlyName
});
} else {
const { id: newCaId } = await createMutateAsync({
projectId,
friendlyName,
keyAlgorithm
});
router.push(`/${ProjectType.SSH}/${projectId}/ca/${newCaId}`);
}
reset();
handlePopUpToggle("sshCa", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} SSH CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SSH CA",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshCa?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshCa", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} SSH CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My SSH CA" />
</FormControl>
)}
/>
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{certKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshCa?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCa", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,120 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SshCaStatus, useDeleteSshCa, useUpdateSshCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshCaModal } from "./SshCaModal";
import { SshCaTable } from "./SshCaTable";
export const SshCaSection = () => {
const { mutateAsync: deleteSshCa } = useDeleteSshCa();
const { mutateAsync: updateSshCa } = useUpdateSshCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshCa",
"deleteSshCa",
"sshCaStatus", // enable / disable
"upgradePlan"
] as const);
const onRemoveSshCaSubmit = async (caId: string) => {
try {
await deleteSshCa({ caId });
await createNotification({
text: "Successfully deleted SSH CA",
type: "success"
});
handlePopUpClose("deleteSshCa");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH CA",
type: "error"
});
}
};
const onUpdateSshCaStatus = async ({ caId, status }: { caId: string; status: SshCaStatus }) => {
try {
await updateSshCa({ caId, status });
await createNotification({
text: `Successfully ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`,
type: "success"
});
handlePopUpClose("sshCaStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshCa")}
isDisabled={!isAllowed}
>
Create SSH CA
</Button>
)}
</ProjectPermissionCan>
</div>
<SshCaTable handlePopUpOpen={handlePopUpOpen} />
<SshCaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshCa.isOpen}
title="Are you sure want to remove the SSH CA?"
onChange={(isOpen) => handlePopUpToggle("deleteSshCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId)
}
/>
<DeleteActionModal
isOpen={popUp.sshCaStatus.isOpen}
title={`Are you sure want to ${
(popUp?.sshCaStatus?.data as { status: string })?.status === SshCaStatus.ACTIVE
? "enable"
: "disable"
} the CA?`}
subTitle={
(popUp?.sshCaStatus?.data as { status: string })?.status === SshCaStatus.ACTIVE
? "This action will allow the SSH CA to start issuing certificates again."
: "This action will prevent the SSH CA from issuing new certificates."
}
onChange={(isOpen) => handlePopUpToggle("sshCaStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateSshCaStatus(popUp?.sshCaStatus?.data as { caId: string; status: SshCaStatus })
}
/>
{/* <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/> */}
</div>
);
};
@@ -0,0 +1,154 @@
import { useRouter } from "next/router";
import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { SshCaStatus, useListWorkspaceSshCas } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSshCa", "sshCaStatus"]>,
data?: {}
) => void;
};
export const SshCaTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter();
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceSshCas(currentWorkspace?.id || "");
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="org-ssh-cas" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map((ca) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() =>
router.push(`/${ProjectType.SSH}/${currentWorkspace?.id}/ca/${ca.id}`)
}
>
<Td>{ca.friendlyName}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{(ca.status === SshCaStatus.ACTIVE ||
ca.status === SshCaStatus.DISABLED) && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshCaStatus", {
caId: ca.id,
status:
ca.status === SshCaStatus.ACTIVE
? SshCaStatus.DISABLED
: SshCaStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${
ca.status === SshCaStatus.ACTIVE ? "Disable" : "Enable"
} SSH CA`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSshCa", {
caId: ca.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete SSH CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.length === 0 && (
<EmptyState
title="No SSH certificate authorities have been created"
icon={faCertificate}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,39 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshCertificateModal } from "../../SshCaPage/components/SshCertificateModal";
import { SshCertificatesTable } from "./SshCertificatesTable";
export const SshCertificatesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["sshCertificate"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificates</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshCertificates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshCertificate")}
isDisabled={!isAllowed}
>
Request
</Button>
)}
</ProjectPermissionCan>
</div>
<SshCertificatesTable />
<SshCertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};
@@ -0,0 +1,87 @@
import { useState } from "react";
import { faCertificate } from "@fortawesome/free-solid-svg-icons";
import { format } from "date-fns";
import {
Badge,
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useListWorkspaceSshCertificates } from "@app/hooks/api";
import { getSshCertStatusBadgeDetails } from "./SshCertificatesTable.utils";
const PER_PAGE_INIT = 25;
export const SshCertificatesTable = () => {
const { currentWorkspace } = useWorkspace();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { data, isLoading } = useListWorkspaceSshCertificates({
projectId: currentWorkspace?.id || "",
offset: (page - 1) * perPage,
limit: perPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Principals</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-ssh-certificates" />}
{!isLoading &&
data?.certificates?.map((certificate) => {
const { variant, label } = getSshCertStatusBadgeDetails(certificate.notAfter);
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.principals.join(", ")}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
</Td>
<Td>
{certificate.notBefore
? format(new Date(certificate.notBefore), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.certificates?.length && (
<EmptyState title="No SSH certificates have been issued" icon={faCertificate} />
)}
</TableContainer>
);
};
@@ -0,0 +1,17 @@
export const getSshCertStatusBadgeDetails = (notAfter: string) => {
const currentDate = new Date().getTime();
const notAfterDate = new Date(notAfter).getTime();
let variant: "success" | "primary" | "danger" = "success";
let label = "Active";
if (notAfterDate > currentDate) {
variant = "success";
label = "Active";
} else {
variant = "danger";
label = "Expired";
}
return { variant, label };
};
@@ -0,0 +1,2 @@
export { SshCaSection } from "./SshCaSection";
export { SshCertificatesSection } from "./SshCertificatesSection";
@@ -0,0 +1 @@
export { SshPage } from "./SshPage";
@@ -7,7 +7,7 @@ export const OrgSettingsPage = () => {
return (
<div className="flex w-full justify-center bg-bunker-800 py-6 text-white">
<div className="w-full max-w-4xl px-6">
<div className="w-full max-w-7xl px-6">
<div className="mb-4">
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
</div>
@@ -0,0 +1,99 @@
import Link from "next/link";
import {
faArrowUpRightFromSquare,
faBookOpen,
faPlus,
faWrench
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { AddAppConnectionModal, AppConnectionsTable } from "./components";
export const AppConnectionsTab = withPermission(
() => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const);
// TODO: remove once live
if (!subscription?.appConnections)
return (
<div className="m-auto mt-40 flex w-full max-w-2xl flex-col items-center rounded-md bg-mineshaft-800 px-2 pt-4 text-bunker-300">
<FontAwesomeIcon icon={faWrench} size="2xl" />
<div className="flex flex-col items-center py-4">
<div className="text-lg text-mineshaft-200">
App Connections are currently unavailable.
</div>
<span className="text-mineshaft-300">Check back soon.</span>
</div>
</div>
);
return (
<div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">App Connections</p>
<Link
href="https://infisical.com/docs/integrations/app-connections/overview"
passHref
>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-[10px]"
/>
</div>
</a>
</Link>
</div>
<p className="text-sm text-bunker-300">
Create and configure connections with third-party apps for re-use across Infisical
projects
</p>
</div>
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addConnection");
}}
isDisabled={!isAllowed}
className="ml-auto"
>
Add Connection
</Button>
)}
</OrgPermissionCan>
</div>
<AppConnectionsTable />
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addConnection", isOpen)}
/>
</div>
</div>
);
},
{
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.AppConnections
}
);
@@ -0,0 +1,47 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { TAppConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { AppConnectionForm } from "./AppConnectionForm";
import { AppConnectionsSelect } from "./AppConnectionList";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (appConnection: TAppConnection) => void;
};
const Content = ({ onComplete }: ContentProps) => {
const [selectedApp, setSelectedApp] = useState<AppConnection | null>(null);
if (selectedApp) {
return (
<AppConnectionForm
onComplete={onComplete}
onBack={() => setSelectedApp(null)}
app={selectedApp}
/>
);
}
return <AppConnectionsSelect onSelect={setSelectedApp} />;
};
export const AddAppConnectionModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Add Connection"
subTitle="Select a third-party app to connect to."
>
<Content onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,117 @@
import { createNotification } from "@app/components/notifications";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import {
TAppConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnectionHeader } from "../AppConnectionHeader";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
} & ({ appConnection: TAppConnection } | { app: AppConnection });
type CreateFormProps = FormProps & { app: AppConnection };
type UpdateFormProps = FormProps & {
appConnection: TAppConnection;
};
const CreateForm = ({ app, onComplete }: CreateFormProps) => {
const createAppConnection = useCreateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
) => {
try {
const connection = await createAppConnection.mutateAsync(formData);
createNotification({
text: `Successfully added ${appName} Connection`,
type: "success"
});
onComplete(connection);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to add ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
switch (app) {
case AppConnection.AWS:
return <AwsConnectionForm onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm />;
default:
throw new Error(`Unhandled App ${app}`);
}
};
const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
const updateAppConnection = useUpdateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
) => {
try {
const connection = await updateAppConnection.mutateAsync({
connectionId: appConnection.id,
...formData
});
createNotification({
text: `Successfully updated ${appName} Connection`,
type: "success"
});
onComplete(connection);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
switch (appConnection.app) {
case AppConnection.AWS:
return <AwsConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
};
type Props = { onBack?: () => void } & Pick<FormProps, "onComplete"> &
(
| { app: AppConnection; appConnection?: undefined }
| { app?: undefined; appConnection: TAppConnection }
);
export const AppConnectionForm = ({ onBack, ...props }: Props) => {
const { app, appConnection } = props;
return (
<div>
<AppConnectionHeader
isConnected={Boolean(appConnection)}
app={appConnection?.app ?? app!}
onBack={onBack}
/>
{appConnection ? (
<UpdateForm {...props} appConnection={appConnection} />
) : (
<CreateForm {...props} app={app} />
)}
</div>
);
};
@@ -0,0 +1,187 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TAwsConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AWS)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: z.object({
roleArn: z.string().trim().min(1, "Role ARN required")
})
}),
rootSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: z.object({
accessKeyId: z.string().trim().min(1, "Access Key ID required"),
secretAccessKey: z.string().trim().min(1, "Secret Access Key required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.AWS,
method: AwsConnectionMethod.AssumeRole
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.AWS].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(AwsConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
{method === AwsConnectionMethod.AssumeRole ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{selectedMethod === AwsConnectionMethod.AssumeRole ? (
<Controller
name="credentials.roleArn"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Role ARN"
className="group"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
) : (
<>
<Controller
name="credentials.accessKeyId"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Key ID"
>
<Input
placeholder={"*".repeat(20)}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
name="credentials.secretAccessKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret Access Key"
className="group"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to AWS"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,42 @@
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
});
export const GenericAppConnectionsFields = () => {
const {
register,
formState: { errors }
} = useFormContext<{ name: string; description?: string | null }>();
return (
<>
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-app-connection" {...register("name")} />
</FormControl>
<FormControl
errorText={errors.description?.message}
isError={Boolean(errors.description?.message)}
label="Description"
isOptional
>
<TextArea
className="h-20 !resize-none"
placeholder="Connection description..."
{...register("description")}
/>
</FormControl>
</>
);
};
@@ -0,0 +1,164 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import {
GitHubConnectionMethod,
TGitHubConnection,
useGetAppConnectionOption
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TGitHubConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.GitHub),
method: z.nativeEnum(GitHubConnectionMethod)
});
type FormData = z.infer<typeof formSchema>;
export const GitHubConnectionForm = ({ appConnection }: Props) => {
const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false);
const {
option: { oauthClientId, appClientSlug },
isLoading
} = useGetAppConnectionOption(AppConnection.GitHub);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.GitHub,
method: GitHubConnectionMethod.App
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"githubConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) {
case GitHubConnectionMethod.App:
window.location.assign(
`https://github.com/apps/${appClientSlug}/installations/new?state=${state}`
);
break;
case GitHubConnectionMethod.OAuth:
window.location.assign(
`https://github.com/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/app-connections/github/oauth/callback&state=${state}`
);
break;
default:
throw new Error(`Unhandled GitHub Connection method: ${(formData as FormData).method}`);
}
};
let isMissingConfig: boolean;
switch (selectedMethod) {
case GitHubConnectionMethod.OAuth:
isMissingConfig = !oauthClientId;
break;
case GitHubConnectionMethod.App:
isMissingConfig = !appClientSlug;
break;
default:
throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`);
}
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.GitHub].name
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See Docs to configure GitHub ${methodDetails.name} Connections.`
}`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{methodDetails.name}{" "}
{method === GitHubConnectionMethod.App ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,2 @@
export * from "./AppConnectionForm";
export * from "./GenericAppConnectionFields";
@@ -0,0 +1,55 @@
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type Props = {
app: AppConnection;
isConnected: boolean;
onBack?: () => void;
};
export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => {
const appDetails = APP_CONNECTION_MAP[app];
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${appDetails.name} logo`}
src={`/images/integrations/${appDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{appDetails.name}
<Link href={`https://infisical.com/docs/integrations/app-connections/${app}`} passHref>
<a target="_blank" className="ml-1 mb-1" rel="noopener noreferrer">
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1 mb-[0.03rem] text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1 mb-[0.07rem] text-[10px]"
/>
</div>
</a>
</Link>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConnected ? `${appDetails.name} Connection` : `Connect to ${appDetails.name}`}
</p>
</div>
{onBack && (
<button
type="button"
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
onClick={onBack}
>
Select another App
</button>
)}
</div>
);
};
@@ -0,0 +1,86 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { useAppConnectionOptions } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type Props = {
onSelect: (app: AppConnection) => void;
};
export const AppConnectionsSelect = ({ onSelect }: Props) => {
const { isLoading, data: appConnectionOptions } = useAppConnectionOptions();
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-5 gap-2">
{appConnectionOptions?.map((option) => (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${APP_CONNECTION_MAP[option.app].image}`}
height={50}
width={50}
className="mt-auto"
alt={`${APP_CONNECTION_MAP[option.app].name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{APP_CONNECTION_MAP[option.app].name}
</div>
</button>
))}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4">
<FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,169 @@
import { useCallback } from "react";
import {
faAsterisk,
faCheck,
faCopy,
faEdit,
faEllipsisV,
faInfoCircle,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { useToggle } from "@app/hooks";
import { TAppConnection } from "@app/hooks/api/appConnections";
type Props = {
appConnection: TAppConnection;
onDelete: (appConnection: TAppConnection) => void;
onEditCredentials: (appConnection: TAppConnection) => void;
onEditDetails: (appConnection: TAppConnection) => void;
};
export const AppConnectionRow = ({
appConnection,
onDelete,
onEditCredentials,
onEditDetails
}: Props) => {
const { id, name, method, app, description } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(id);
createNotification({
text: "Connection ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const methodDetails = getAppConnectionMethodDetails(method);
return (
<Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
key={`app-connection-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="mr-0.5 h-5 w-5"
/>
<span className="hidden lg:inline">{APP_CONNECTION_MAP[app].name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{name}</p>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<p className="truncate">
<FontAwesomeIcon
size="sm"
className="mr-1.5 text-mineshaft-300/75"
icon={methodDetails.icon}
/>
{methodDetails.name}
</p>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,321 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faPlug,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { AppConnectionRow } from "./AppConnectionRow";
import { DeleteAppConnectionModal } from "./DeleteAppConnectionModal";
import { EditAppConnectionCredentialsModal } from "./EditAppConnectionCredentialsModal";
import { EditAppConnectionDetailsModal } from "./EditAppConnectionDetailsModal";
enum AppConnectionsOrderBy {
App = "app",
Name = "name",
Method = "method"
}
type AppConnectionFilters = {
apps: AppConnection[];
};
export const AppConnectionsTable = () => {
const { subscription } = useSubscription();
const { isLoading, data: appConnections = [] } = useListAppConnections({
enabled: subscription?.appConnections
});
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteConnection",
"editCredentials",
"editDetails"
] as const);
const [filters, setFilters] = useState<AppConnectionFilters>({
apps: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, { initPerPage: 20 });
const filteredAppConnections = useMemo(
() =>
appConnections
.filter((appConnection) => {
const { app, method, name } = appConnection;
if (filters.apps.length && !filters.apps.includes(app)) return false;
const searchValue = search.trim().toLowerCase();
return (
APP_CONNECTION_MAP[app].name.toLowerCase().includes(searchValue) ||
getAppConnectionMethodDetails(method).name.toLowerCase().includes(searchValue) ||
name.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [connectionOne, connectionTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case AppConnectionsOrderBy.Name:
return connectionOne.name
.toLowerCase()
.localeCompare(connectionTwo.name.toLowerCase());
case AppConnectionsOrderBy.Method:
return getAppConnectionMethodDetails(connectionOne.method)
.name.toLowerCase()
.localeCompare(
getAppConnectionMethodDetails(connectionTwo.method).name.toLowerCase()
);
case AppConnectionsOrderBy.App:
default:
return APP_CONNECTION_MAP[connectionOne.app].name
.toLowerCase()
.localeCompare(APP_CONNECTION_MAP[connectionTwo.app].name.toLowerCase());
}
}),
[appConnections, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredAppConnections.length,
offset,
setPage
});
const handleSort = (column: AppConnectionsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: AppConnectionsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: AppConnectionsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.apps.length);
const handleDelete = (appConnection: TAppConnection) =>
handlePopUpOpen("deleteConnection", appConnection);
const handleEditCredentials = (appConnection: TAppConnection) =>
handlePopUpOpen("editCredentials", appConnection);
const handleEditDetails = (appConnection: TAppConnection) =>
handlePopUpOpen("editDetails", appConnection);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Connections"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Filter by Apps</DropdownMenuLabel>
{appConnections.length ? (
[...new Set(appConnections.map(({ app }) => app))].map((app) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
apps: prev.apps.includes(app)
? prev.apps.filter((a) => a !== app)
: [...prev.apps, app]
}));
}}
key={app}
icon={
filters.apps.includes(app) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="h-4 w-4"
/>
<span>{APP_CONNECTION_MAP[app].name}</span>
</div>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem isDisabled>No Connections Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/4">
<div className="flex items-center">
App
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.App)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.App)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.App)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Method
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.Method)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.Method)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.Method)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && (
<TableSkeleton innerKey="app-connections-table" columns={4} key="app-connections" />
)}
{filteredAppConnections.slice(offset, perPage * page).map((connection) => (
<AppConnectionRow
appConnection={connection}
key={connection.id}
onDelete={handleDelete}
onEditCredentials={handleEditCredentials}
onEditDetails={handleEditDetails}
/>
))}
</TBody>
</Table>
{Boolean(filteredAppConnections.length) && (
<Pagination
count={filteredAppConnections.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredAppConnections?.length && (
<EmptyState
title={
appConnections.length
? "No App Connections match search..."
: "No App Connections have been configured"
}
icon={appConnections.length ? faSearch : faPlug}
/>
)}
</TableContainer>
<DeleteAppConnectionModal
isOpen={popUp.deleteConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteConnection", isOpen)}
appConnection={popUp.deleteConnection.data}
/>
<EditAppConnectionCredentialsModal
isOpen={popUp.editCredentials.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
appConnection={popUp.editCredentials.data}
/>
<EditAppConnectionDetailsModal
isOpen={popUp.editDetails.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editDetails", isOpen)}
appConnection={popUp.editDetails.data}
/>
</div>
);
};
@@ -0,0 +1,51 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections";
type Props = {
appConnection?: TAppConnection;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }: Props) => {
const deleteAppConnection = useDeleteAppConnection();
if (!appConnection) return null;
const { id: connectionId, name, app } = appConnection;
const handleDeleteAppConnection = async () => {
try {
await deleteAppConnection.mutateAsync({
connectionId,
app
});
createNotification({
text: `Successfully removed ${APP_CONNECTION_MAP[app].name} connection`,
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed remove ${APP_CONNECTION_MAP[app].name} connection`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey="confirm"
onDeleteApproved={handleDeleteAppConnection}
/>
);
};
@@ -0,0 +1,33 @@
import { Modal, ModalContent } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection } from "@app/hooks/api/appConnections";
import { AppConnectionForm } from "./AppConnectionForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
appConnection?: TAppConnection;
};
export const EditAppConnectionCredentialsModal = ({
isOpen,
onOpenChange,
appConnection
}: Props) => {
if (!appConnection) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Connection Credentials"
subTitle={`Update the credentials for this ${
appConnection ? APP_CONNECTION_MAP[appConnection.app].name : "App"
} Connection.`}
>
<AppConnectionForm onComplete={() => onOpenChange(false)} appConnection={appConnection} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,109 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/lib/types";
import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields } from "./AppConnectionForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
appConnection?: TAppConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.nativeEnum(AppConnection)
});
type FormData = z.infer<typeof formSchema>;
type ContentProps = { appConnection: TAppConnection; onComplete: () => void };
const Content = ({ appConnection, onComplete }: ContentProps) => {
const updateAppConnection = useUpdateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: appConnection.name,
app: appConnection.app,
description: appConnection.description
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
const onSubmit = async (formData: DiscriminativePick<TAppConnection, "name" | "app">) => {
try {
await updateAppConnection.mutateAsync({
connectionId: appConnection.id,
...formData
});
createNotification({
text: `Successfully updated ${appName} Connection`,
type: "success"
});
onComplete();
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericAppConnectionsFields />
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Update Details
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
export const EditAppConnectionDetailsModal = ({ isOpen, onOpenChange, appConnection }: Props) => {
if (!appConnection) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Connection Name"
subTitle={`Update the name for this ${
appConnection ? APP_CONNECTION_MAP[appConnection.app].name : "App"
} Connection.`}
>
<Content appConnection={appConnection} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,2 @@
export * from "./AddAppConnectionModal";
export * from "./AppConnectionsTable";
@@ -0,0 +1 @@
export { AppConnectionsTab } from "./AppConnectionsTab";
@@ -4,6 +4,7 @@ import { Tab } from "@headlessui/react";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { AppConnectionsTab } from "@app/views/Settings/OrgSettingsPage/components/AppConnectionsTab";
import { ProjectTemplatesTab } from "@app/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab";
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
@@ -18,6 +19,7 @@ const tabs = [
{ name: "Security", key: "tab-org-security" },
{ name: "Encryption", key: "tab-org-encryption" },
{ name: "Workflow Integrations", key: "workflow-integrations" },
{ name: "App Connections", key: "app-connections" },
{ name: "Audit Log Streams", key: "tag-audit-log-streams" },
{ name: "Import", key: "tab-import" },
{ name: "Project Templates", key: "project-templates" }
@@ -67,6 +69,9 @@ export const OrgTabGroup = () => {
<Tab.Panel>
<OrgWorkflowIntegrationTab />
</Tab.Panel>
<Tab.Panel>
<AppConnectionsTab />
</Tab.Panel>
<Tab.Panel>
<AuditLogStreamsTab />
</Tab.Panel>