Merge remote-tracking branch 'origin/main' into feat/add-version-limits

This commit is contained in:
Sheen Capadngan
2024-06-08 01:37:14 +08:00
126 changed files with 3741 additions and 729 deletions
+2 -1
View File
@@ -32,7 +32,8 @@ const integrationSlugNameMapping: Mapping = {
northflank: "Northflank",
windmill: "Windmill",
"gcp-secret-manager": "GCP Secret Manager",
"hasura-cloud": "Hasura Cloud"
"hasura-cloud": "Hasura Cloud",
rundeck: "Rundeck"
};
const envMapping: Mapping = {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="45.359 114.637 60.122 58.576"><path d="M46.83 113.864l7.608 12.01H92.5l-7.543-12.01zm15.26 23.98l3.684 5.754-3.968 6.32h38.4l3.815-6.017-3.815-5.907h-38.04zm-7.826 24.13l-7.455 11.77v.24h38.148l7.564-12.012z" fill="#f91629"/></svg>

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 212 KiB

+2 -1
View File
@@ -62,6 +62,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
<SelectPrimitive.Content
className={twMerge(
"relative top-1 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md",
position === "popper" && "max-h-72",
dropdownContainerClassName
)}
position={position}
@@ -113,7 +114,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`,
isSelected && "bg-primary",
isDisabled &&
"cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600",
"cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600",
className
)}
ref={forwardedRef}
+5
View File
@@ -0,0 +1,5 @@
export const removeTrailingSlash = (str: string) => {
if (str === "/") return str;
return str.endsWith("/") ? str.slice(0, -1) : str;
};
@@ -7,6 +7,7 @@ export type IntegrationAuth = {
updatedAt: string;
algorithm: string;
keyEncoding: string;
url?: string;
teamId?: string;
};
@@ -41,6 +41,7 @@ export const useCreateIntegration = () => {
owner,
path,
region,
url,
scope,
secretPath,
metadata
@@ -56,6 +57,7 @@ export const useCreateIntegration = () => {
targetService?: string;
targetServiceId?: string;
owner?: string;
url?: string;
path?: string;
region?: string;
scope?: string;
@@ -85,6 +87,7 @@ export const useCreateIntegration = () => {
targetEnvironmentId,
targetService,
targetServiceId,
url,
owner,
path,
scope,
@@ -220,6 +220,7 @@ export const useGetSecretApprovalRequestCount = ({
}) =>
useQuery({
queryKey: secretApprovalRequestKeys.count({ workspaceId }),
refetchInterval: 5000,
queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
});
@@ -44,6 +44,7 @@ export type TSecretApprovalSecChange = {
export type TSecretApprovalRequest<J extends unknown = EncryptedSecret> = {
id: string;
isReplicated?: boolean;
slug: string;
createdAt: string;
committerId: string;
@@ -1,3 +1,7 @@
export enum ReservedFolders {
SecretReplication = "__reserve_replication_"
}
export type TSecretFolder = {
id: string;
name: string;
@@ -1,4 +1,9 @@
export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation";
export {
useCreateSecretImport,
useDeleteSecretImport,
useResyncSecretReplication,
useUpdateSecretImport
} from "./mutation";
export {
useGetImportedFoldersByEnv,
useGetImportedSecretsAllEnvs,
@@ -3,18 +3,24 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretImportKeys } from "./queries";
import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types";
import {
TCreateSecretImportDTO,
TDeleteSecretImportDTO,
TResyncSecretReplicationDTO,
TUpdateSecretImportDTO
} from "./types";
export const useCreateSecretImport = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretImportDTO>({
mutationFn: async ({ import: secretImport, environment, projectId, path }) => {
mutationFn: async ({ import: secretImport, environment, isReplication, projectId, path }) => {
const { data } = await apiRequest.post("/api/v1/secret-imports", {
import: secretImport,
environment,
workspaceId: projectId,
path
path,
isReplication
});
return data;
},
@@ -53,6 +59,19 @@ export const useUpdateSecretImport = () => {
});
};
export const useResyncSecretReplication = () => {
return useMutation<{}, {}, TResyncSecretReplicationDTO>({
mutationFn: async ({ environment, projectId, path, id }) => {
const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, {
environment,
path,
workspaceId: projectId
});
return data;
}
});
};
export const useDeleteSecretImport = () => {
const queryClient = useQueryClient();
@@ -10,6 +10,11 @@ export type TSecretImport = {
position: string;
createdAt: string;
updatedAt: string;
isReserved?: boolean;
isReplication?: boolean;
isReplicationSuccess?: boolean;
replicationStatus?: string;
lastReplicated?: string;
};
export type TGetImportedFoldersByEnvDTO = {
@@ -60,6 +65,7 @@ export type TCreateSecretImportDTO = {
environment: string;
path: string;
};
isReplication?: boolean;
};
export type TUpdateSecretImportDTO = {
@@ -74,6 +80,13 @@ export type TUpdateSecretImportDTO = {
}>;
};
export type TResyncSecretReplicationDTO = {
id: string;
projectId: string;
environment: string;
path?: string;
};
export type TDeleteSecretImportDTO = {
id: string;
projectId: string;
@@ -8,9 +8,7 @@ export const useGetSharedSecrets = () => {
return useQuery({
queryKey: ["sharedSecrets"],
queryFn: async () => {
const { data } = await apiRequest.get<TSharedSecret[]>(
"/api/v1/secret-sharing/"
);
const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/");
return data;
}
});
@@ -23,11 +21,9 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex:
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
);
return {
name: data.name,
encryptedValue: data.encryptedValue,
iv: data.iv,
tag: data.tag,
expiresAt: data.expiresAt
};
}
});
+3 -10
View File
@@ -1,31 +1,24 @@
export type TSharedSecret = {
id: string;
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
userId: string;
expiresAt: Date;
orgId: string;
createdAt: Date;
updatedAt: Date;
};
} & TCreateSharedSecretRequest;
export type TCreateSharedSecretRequest = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAfterViews: number;
};
export type TViewSharedSecretResponse = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
expiresAt: Date;
};
export type TDeleteSharedSecretRequest = {
+9
View File
@@ -0,0 +1,9 @@
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
export const formatReservedPaths = (secretPath: string) => {
const i = secretPath.indexOf(ReservedFolders.SecretReplication);
if (i !== -1) {
return `${secretPath.slice(0, i)} - (replication)`;
}
return secretPath;
};
@@ -7,7 +7,15 @@ import { createNotification } from "@app/components/notifications";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api";
import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2";
import {
Button,
Card,
CardTitle,
FormControl,
Select,
SelectItem,
Switch
} from "../../../components/v2";
import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById
@@ -34,6 +42,7 @@ export default function CloudflarePagesIntegrationPage() {
const [targetApp, setTargetApp] = useState("");
const [targetAppId, setTargetAppId] = useState("");
const [targetEnvironment, setTargetEnvironment] = useState("");
const [shouldAutoRedeploy, setShouldAutoRedeploy] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -69,7 +78,10 @@ export default function CloudflarePagesIntegrationPage() {
appId: targetAppId,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment,
secretPath
secretPath,
metadata: {
shouldAutoRedeploy
}
});
setIsLoading(false);
@@ -169,6 +181,15 @@ export default function CloudflarePagesIntegrationPage() {
))}
</Select>
</FormControl>
<div className="mb-[2.36rem] ml-1 px-6">
<Switch
id="redeploy-cloudflare-pages"
onCheckedChange={(isChecked: boolean) => setShouldAutoRedeploy(isChecked)}
isChecked={shouldAutoRedeploy}
>
Auto-redeploy service upon secret change
</Switch>
</div>
<Button
onClick={handleButtonClick}
color="mineshaft"
@@ -0,0 +1,129 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2";
import { useSaveIntegrationAccessToken } from "@app/hooks/api";
const schema = z.object({
authToken: z.string().trim().min(1, { message: "Rundeck Auth Token is required" }),
rundeckURL: z.string().trim().min(1, {
message: "Rundeck URL is required"
})
});
type FormData = z.infer<typeof schema>;
export default function RundeckAuthorizeIntegrationPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync } = useSaveIntegrationAccessToken();
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
authToken: "",
rundeckURL: ""
}
});
const onFormSubmit = async ({ authToken, rundeckURL }: FormData) => {
try {
setIsLoading(true);
const integrationAuth = await mutateAsync({
workspaceId: localStorage.getItem("projectData.id"),
integration: "rundeck",
accessToken: authToken,
url: rundeckURL.trim()
});
setIsLoading(false);
router.push(`/integrations/rundeck/create?integrationAuthId=${integrationAuth.id}`);
} catch (err) {
setIsLoading(false);
console.error(err);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<Head>
<title>Authorize Rundeck Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="mb-12 max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="After adding your URL and auth token, you will be prompted to set up an integration for a particular Infisical project and environment."
>
<div className="flex flex-row items-center">
<div className="flex items-center pb-0.5">
<Image
src="/images/integrations/Rundeck.svg"
height={30}
width={30}
alt="Rundeck logo"
/>
</div>
<span className="ml-2.5">Rundeck Integration </span>
<Link href="https://infisical.com/docs/integrations/cicd/rundeck" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<form onSubmit={handleSubmit(onFormSubmit)} className="px-6 pb-8 text-right">
<Controller
control={control}
name="rundeckURL"
render={({ field, fieldState: { error } }) => (
<FormControl label="URL" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="https://self-hosted-rundeck.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="authToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Rundeck Auth Token"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="" />
</FormControl>
)}
/>
<Button
colorSchema="primary"
variant="outline_bg"
className="mt-2 w-min"
size="sm"
type="submit"
isLoading={isLoading}
>
Connect to Rundeck
</Button>
</form>
</Card>
</div>
);
}
RundeckAuthorizeIntegrationPage.requireAuth = true;
@@ -0,0 +1,217 @@
import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import queryString from "query-string";
import { z } from "zod";
import {
Button,
Card,
CardTitle,
FormControl,
Input,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useCreateIntegration } from "@app/hooks/api";
import { useGetIntegrationAuthById } from "@app/hooks/api/integrationAuth";
import { useGetWorkspaceById } from "@app/hooks/api/workspace";
const schema = z.object({
keyStoragePath: z.string().trim().min(1, { message: "Rundeck Key Storage path is required" }),
secretPath: z.string().trim().min(1, { message: "Secret path is required" }),
sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" })
});
type TFormSchema = z.infer<typeof schema>;
export default function RundeckCreateIntegrationPage() {
const {
control,
handleSubmit,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(schema),
defaultValues: {
secretPath: "/"
}
});
const router = useRouter();
const { mutateAsync } = useCreateIntegration();
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]);
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? "");
const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById(
(integrationAuthId as string) ?? ""
);
const selectedSourceEnvironment = watch("sourceEnvironment");
const onFormSubmit = async ({ secretPath, sourceEnvironment, keyStoragePath }: TFormSchema) => {
try {
if (!integrationAuth?.id) return;
await mutateAsync({
integrationAuthId: integrationAuth?.id,
isActive: true,
path: keyStoragePath,
sourceEnvironment,
url: integrationAuth.url,
secretPath
});
router.push(`/integrations/${localStorage.getItem("projectData.id")}`);
} catch (err) {
console.error(err);
}
};
return integrationAuth && workspace ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<Head>
<title>Set Up Rundeck Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Choose which environment or folder in Infisical you want to sync to the Rundeck Key Storage."
>
<div className="flex flex-row items-center">
<div className="flex items-center pb-0.5">
<Image
src="/images/integrations/Rundeck.svg"
height={30}
width={30}
alt="Rundeck logo"
/>
</div>
<span className="ml-2.5">Rundeck Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/flyio" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col px-6">
<Controller
control={control}
name="sourceEnvironment"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Project Environment"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
className="w-full border border-mineshaft-500"
value={field.value}
onValueChange={(val) => {
field.onChange(val);
}}
>
{workspace?.environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secrets Path" errorText={error?.message} isError={Boolean(error)}>
<SecretPathInput {...field} environment={selectedSourceEnvironment} />
</FormControl>
)}
/>
<Controller
control={control}
name="keyStoragePath"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Rundeck Key Storage Path"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
placeholder={`keys/project/${workspace.name
.toLowerCase()
.replace(/ /g, "-")}/${selectedSourceEnvironment}`}
{...field}
/>
</FormControl>
)}
/>
<Button
type="submit"
color="mineshaft"
variant="outline_bg"
className="mb-6 mt-2 ml-auto"
isLoading={isSubmitting}
>
Create Integration
</Button>
</form>
</Card>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Head>
<title>Set Up Rundeck Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isIntegrationAuthLoading ? (
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="infisical loading indicator"
/>
) : (
<div className="flex h-max max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6 text-center text-mineshaft-200">
<FontAwesomeIcon icon={faBugs} className="inlineli my-2 text-6xl" />
<p>
Something went wrong. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:[email protected]"
>
support@infisical.com
</a>{" "}
if the issue persists.
</p>
</div>
)}
</div>
);
}
RundeckCreateIntegrationPage.requireAuth = true;
@@ -128,6 +128,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
case "hasura-cloud":
link = `${window.location.origin}/integrations/hasura-cloud/authorize`;
break;
case "rundeck":
link = `${window.location.origin}/integrations/rundeck/authorize`;
break;
default:
break;
}
@@ -141,7 +141,8 @@ export const IntegrationsSection = ({
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(integration.integration === "aws-parameter-store" && "Path") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
"Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
@@ -153,7 +154,7 @@ export const IntegrationsSection = ({
{(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(integration.integration === "aws-parameter-store" &&
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
`${integration.path}`) ||
(integration.scope?.startsWith("github-") &&
`${integration.owner}/${integration.app}`) ||
@@ -43,6 +43,7 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { removeTrailingSlash } from "@app/helpers/string";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
@@ -104,7 +105,9 @@ export const SpecificPrivilegeSecretForm = ({
? {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob
? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob)
: "",
read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read)
),
@@ -183,7 +186,7 @@ export const SpecificPrivilegeSecretForm = ({
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) };
}
await updateUserPrivilege.mutateAsync({
privilegeId: privilege.id,
@@ -212,7 +212,8 @@ export const SecretApprovalRequest = () => {
createdAt,
policy,
reviewers,
status
status,
isReplicated: isReplication
} = secretApproval;
const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1;
const isReviewed =
@@ -240,8 +241,9 @@ export const SecretApprovalRequest = () => {
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{membersGroupById?.[committerId]?.user?.firstName}{" "}
{membersGroupById?.[committerId]?.user?.lastName} (
{membersGroupById?.[committerId]?.user?.email}){" "}
{isApprover && !isReviewed && status === "open" && "- Review required"}
{membersGroupById?.[committerId]?.user?.email})
{isReplication && " via replication"}
{isApprover && !isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
@@ -20,6 +20,7 @@ import {
useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
@@ -185,6 +186,9 @@ export const SecretApprovalRequestChanges = ({
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg">
{generateCommitText(secretApprovalRequestDetails.commits)}
{secretApprovalRequestDetails.isReplicated && (
<span className="text-sm text-bunker-300"> (replication)</span>
)}
</div>
<div className="flex items-center text-sm text-bunker-300">
{committer?.user?.firstName}
@@ -197,7 +201,11 @@ export const SecretApprovalRequestChanges = ({
<div className="border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div>
<div className="pl-2 pb-0.5 text-sm">{secretApprovalRequestDetails.secretPath}</div>
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<div className="truncate pl-2 pb-0.5 text-sm" style={{ maxWidth: "10rem" }}>
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</div>
</Tooltip>
</div>
</div>
</div>
@@ -482,6 +482,7 @@ export const ActionBar = ({
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isOpen={popUp.addSecretImport.isOpen}
onClose={() => handlePopUpClose("addSecretImport")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
@@ -4,9 +4,16 @@ import { AxiosError } from "axios";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import {
Button,
FormControl,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { useSubscription, useWorkspace } from "@app/context";
import { useCreateSecretImport } from "@app/hooks/api";
const typeSchema = z.object({
@@ -16,7 +23,8 @@ const typeSchema = z.object({
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
),
isReplication: z.boolean().default(false)
});
type TFormSchema = z.infer<typeof typeSchema>;
@@ -29,6 +37,7 @@ type Props = {
isOpen?: boolean;
onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void;
onUpgradePlan: () => void;
};
export const CreateSecretImportForm = ({
@@ -37,7 +46,8 @@ export const CreateSecretImportForm = ({
secretPath = "/",
isOpen,
onClose,
onTogglePopUp
onTogglePopUp,
onUpgradePlan
}: Props) => {
const {
handleSubmit,
@@ -49,18 +59,26 @@ export const CreateSecretImportForm = ({
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const selectedEnvironment = watch("environment");
const { subscription } = useSubscription();
const { mutateAsync: createSecretImport } = useCreateSecretImport();
const handleFormSubmit = async ({
environment: importedEnv,
secretPath: importedSecPath
secretPath: importedSecPath,
isReplication
}: TFormSchema) => {
try {
if (isReplication && !subscription?.secretApproval) {
onUpgradePlan();
return;
}
await createSecretImport({
environment,
projectId: workspaceId,
path: secretPath,
isReplication,
import: {
environment: importedEnv,
path: importedSecPath
@@ -70,7 +88,8 @@ export const CreateSecretImportForm = ({
reset();
createNotification({
type: "success",
text: "Successfully linked"
text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : ""
}`
});
} catch (err) {
console.error(err);
@@ -127,7 +146,31 @@ export const CreateSecretImportForm = ({
</FormControl>
)}
/>
<Controller
name="isReplication"
control={control}
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
value
? "Secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately."
: "Secrets from the source location will be imported to the selected destination immediately, ignoring any approval policies at the destination."
}
>
<Select
value={value ? "true" : "false"}
onValueChange={(val) => onChange(val === "true")}
className="w-full border border-mineshaft-500"
>
<SelectItem value="false">Ignore secret approval polices</SelectItem>
<SelectItem value="true">Respect secret approval polices</SelectItem>
</Select>
</FormControl>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
@@ -2,36 +2,61 @@ import { useEffect } from "react";
import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable";
import {
faCalendarCheck,
faClose,
faFileImport,
faFolder,
faInfoCircle,
faKey,
faUpDown
faRotate,
faUpDown,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import {
EmptyState,
IconButton,
SecretInput,
TableContainer,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useResyncSecretReplication } from "@app/hooks/api";
import { TSecretImport } from "@app/hooks/api/types";
type Props = {
onDelete: () => void;
environment: string;
secretPath?: string;
importEnvName: string;
importEnvPath: string;
secretImport?: TSecretImport;
isReplicationExpand?: boolean;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string;
id: string;
onExpandReplicateSecrets: (id: string) => void;
};
// to show the environment and folder icon
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
export const EnvFolderIcon = ({
env,
secretPath,
// isReplication
}: {
env: string;
secretPath: string;
// isReplication?: boolean;
}) => (
<div className="inline-flex items-center space-x-2">
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
{secretPath && (
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
{/* {isReplication && <Tag size="xs">Replication Mode</Tag>} */}
<FontAwesomeIcon icon={faFolder} className="text-md text-green-700" />
<span>{secretPath}</span>
</div>
@@ -41,19 +66,29 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st
export const SecretImportItem = ({
onDelete,
id,
importEnvName,
importEnvPath,
isReplicationExpand,
importedSecrets = [],
searchTerm = "",
secretPath,
environment
environment,
secretImport,
onExpandReplicateSecrets: onExpandReplicate
}: Props) => {
const {
isReserved,
id,
isReplication,
isReplicationSuccess,
replicationStatus,
lastReplicated,
importEnv
} = secretImport as TSecretImport;
const { currentWorkspace } = useWorkspace();
const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id
});
const resyncSecretReplication = useResyncSecretReplication();
useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
@@ -77,22 +112,127 @@ export const SecretImportItem = ({
transition
};
const handleResyncSecretReplication = async () => {
if (resyncSecretReplication.isLoading) return;
try {
await resyncSecretReplication.mutateAsync({
id,
environment,
path: secretPath,
projectId: currentWorkspace?.id || ""
});
createNotification({
text: "Please refresh the dashboard to view changes",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to resync replication",
type: "error"
});
}
};
const handleRowClick = () => {
if (isReplication) {
onExpandReplicate(id);
} else {
setIsExpanded.toggle();
}
};
return (
<>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
className={twMerge(
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
isReserved && "hidden"
)}
role="button"
ref={setNodeRef}
tabIndex={0}
style={style}
onClick={() => setIsExpanded.toggle()}
onKeyDown={() => setIsExpanded.toggle()}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRowClick();
}
}}
>
<div className="flex w-12 items-center px-4 py-2 text-green-700">
<FontAwesomeIcon icon={faFileImport} />
</div>
<div className="flex flex-grow items-center px-4 py-2">
<EnvFolderIcon env={importEnvName || ""} secretPath={importEnvPath} />
<EnvFolderIcon
env={importEnv.slug || ""}
secretPath={secretImport?.importPath || ""}
// isReplication={isReplication}
/>
</div>
<div className="flex items-center space-x-4 px-4 py-2">
{lastReplicated && (
<Tooltip
position="left"
className="max-w-md whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last Replication</div>
</div>
<div className="pl-5 text-left text-xs">
{lastReplicated
? format(new Date(lastReplicated), "yyyy-MM-dd, hh:mm aaa")
: "-"}
</div>
{!isReplicationSuccess && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">{replicationStatus}</div>
</>
)}
</div>
}
>
<div
className={twMerge(
"opacity-0 group-hover:opacity-100",
!isReplicationSuccess && "text-red-600"
)}
>
<FontAwesomeIcon icon={isReplicationSuccess ? faInfoCircle : faWarning} />
</div>
</Tooltip>
)}
{isReplication && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Resync replicated secrets"
>
{(isAllowed) => (
<IconButton
size="md"
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
resyncSecretReplication.isLoading && "animate-spin opacity-100"
)}
isDisabled={!isAllowed}
onClick={handleResyncSecretReplication}
>
<FontAwesomeIcon icon={faRotate} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan
@@ -141,7 +281,7 @@ export const SecretImportItem = ({
</ProjectPermissionCan>
</div>
</div>
{isExpanded && !isDragging && (
{!isReplication && (isReplicationExpand || isExpanded) && !isDragging && (
<td
colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
@@ -16,8 +16,10 @@ import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret, WorkspaceEnv } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretImportItem } from "./SecretImportItem";
@@ -50,7 +52,7 @@ export const computeImportedSecretRows = (
importSecrets[i].secrets.forEach((el) => {
overridenSec[el.key] = {
env: importSecrets[i].environmentInfo.name,
secretPath: importSecrets[i].secretPath
secretPath: formatReservedPaths(importSecrets[i].secretPath)
};
});
}
@@ -90,7 +92,9 @@ export const SecretImportListView = ({
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecretImport"
] as const);
const [replicationSecrets, setReplicationSecrets] = useState<Record<string, boolean>>({});
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
@@ -149,6 +153,23 @@ export const SecretImportListView = ({
}
};
const handleOpenReplicationSecrets = (replicationImportId: string) => {
const reservedImport = secretImports.find(
({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment &&
isReserved &&
importPath ===
`${secretPath === "/" ? "" : secretPath}/${ReservedFolders.SecretReplication
}${replicationImportId}`
);
if (reservedImport) {
setReplicationSecrets((state) => ({
...state,
[reservedImport.id]: !state?.[reservedImport.id]
}));
}
};
return (
<>
<DndContext
@@ -159,17 +180,17 @@ export const SecretImportListView = ({
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map((item) => {
const { importPath, importEnv, id } = item;
// TODO(akhilmhdh): change this and pass this whole object instead of one by one
return (
<SecretImportItem
searchTerm={searchTerm}
key={`imported-env-${id}`}
id={id}
importEnvPath={importPath}
importEnvName={importEnv.name}
key={`imported-env-${item.id}`}
isReplicationExpand={replicationSecrets?.[item.id]}
onExpandReplicateSecrets={handleOpenReplicationSecrets}
secretImport={item}
importedSecrets={computeImportedSecretRows(
importEnv.slug,
importPath,
item.importEnv.slug,
item.importPath,
importedSecrets,
secrets
)}
@@ -185,9 +206,8 @@ export const SecretImportListView = ({
isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv
} of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`}
subTitle={`This will unlink secrets from environment ${(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv
} of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>
@@ -420,9 +420,8 @@ export const SecretItem = memo(
<Tooltip
content={
secretReminderRepeatDays && secretReminderRepeatDays > 0
? `Every ${secretReminderRepeatDays} day${
Number(secretReminderRepeatDays) > 1 ? "s" : ""
}
? `Every ${secretReminderRepeatDays} day${Number(secretReminderRepeatDays) > 1 ? "s" : ""
}
`
: "Reminder"
}
@@ -492,7 +491,7 @@ export const SecretItem = memo(
ariaLabel="more"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100 h-5 w-4"
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)}
>
<FontAwesomeSymbol
@@ -3,6 +3,7 @@ import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faClock,
faClone,
faClose,
faCodeBranch,
faComment,
@@ -64,7 +65,8 @@ export enum FontAwesomeSpriteName {
More = "more",
Override = "secret-override",
Close = "close",
CheckedCircle = "check-circle"
CheckedCircle = "check-circle",
ReplicatedSecretKey = "secret-replicated"
}
// this is an optimization technique
@@ -79,5 +81,6 @@ export const FontAwesomeSpriteSymbols = [
{ icon: faEllipsis, symbol: FontAwesomeSpriteName.More },
{ icon: faCodeBranch, symbol: FontAwesomeSpriteName.Override },
{ icon: faClose, symbol: FontAwesomeSpriteName.Close },
{ icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle }
{ icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle },
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey }
];
@@ -13,14 +13,16 @@ export const ShareSecretPage = () => {
<p className="text-bunker-300">Share secrets securely using a shareable link</p>
</div>
<div className="flex w-max justify-center">
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</div>
</a>
</Link>
</div>
</div>
@@ -9,9 +9,7 @@ import { AxiosError } from "axios";
import * as yup from "yup";
import { createNotification } from "@app/components/notifications";
import {
encryptSymmetric,
} from "@app/components/utilities/cryptography/crypto";
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import {
Button,
FormControl,
@@ -49,22 +47,12 @@ const expirationUnitsAndActions = [
unit: "Weeks",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7)
},
{
unit: "Months",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setMonth(expiresAt.getMonth() + expiresInValue)
},
{
unit: "Years",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setFullYear(expiresAt.getFullYear() + expiresInValue)
}
];
const schema = yup.object({
name: yup.string().max(100).required().label("Shared Secret Name"),
value: yup.string().max(1000).required().label("Shared Secret Value"),
value: yup.string().max(10000).required().label("Shared Secret Value"),
expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit")
});
@@ -93,7 +81,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const [newSharedSecret, setnewSharedSecret] = useState("");
const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false,
initialState: false
});
const copyUrlToClipboard = () => {
@@ -106,10 +94,14 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
}
}, [isUrlCopied]);
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => {
const onFormSubmit = async ({
value,
expiresInValue,
expiresInUnit,
expiresAfterViews
}: FormData) => {
try {
if (!currentOrg?.id) return;
const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
@@ -117,25 +109,26 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
key
});
const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit
)?.action;
if (updateExpiresAt) {
if (updateExpiresAt && expiresInValue) {
updateExpiresAt(expiresAt, expiresInValue);
}
const { id } = await createSharedSecret.mutateAsync({
name,
encryptedValue: ciphertext,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews
});
setnewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}`
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
);
createNotification({
@@ -168,90 +161,100 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
setnewSharedSecret("");
}}
>
<ModalContent
title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. "
>
<ModalContent
title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. "
>
{!hasSharedSecret ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your secret identifier" />
</FormControl>
)}
/>
<Controller
control={control}
name="value"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret Value"
label="Shared Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isVisible
{...field}
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2"
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 min-h-[100px]"
/>
</FormControl>
)}
/>
<div className="flex w-full flex-row justify-end">
<div className="w-3/5">
<div className="flex w-full flex-row">
<div className="w-2/7 flex">
<Controller
control={control}
name="expiresInValue"
defaultValue={1}
name="expiresAfterViews"
defaultValue={6}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expiration Value"
className="mb-4 w-full"
label="Expires After Views"
isError={Boolean(error)}
errorText={error?.message}
errorText="Please enter a valid number of views"
>
<Input {...field} type="number" min={0} />
<Input {...field} type="number" min={1} />
</FormControl>
)}
/>
</div>
<div className="w-2/5 pl-4">
<Controller
control={control}
name="expiresInUnit"
defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="w-1/7 flex items-center justify-center px-2">
<p className="px-4 text-sm text-gray-400">OR</p>
</div>
<div className="w-4/7 flex">
<div className="flex w-full">
<div className="flex w-2/5 w-full justify-center">
<Controller
control={control}
name="expiresInValue"
defaultValue={6}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expires after Time"
isError={Boolean(error)}
errorText="Please enter a valid time duration"
>
<Input {...field} type="number" min={0} />
</FormControl>
)}
/>
</div>
<div className="flex w-3/5 w-full justify-center">
<Controller
control={control}
name="expiresInUnit"
defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-8 flex items-center">
<div className="flex items-center">
<Button
className="mr-4"
type="submit"
@@ -8,7 +8,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
const isExpired = (expiresAt: Date): boolean => new Date(expiresAt) < new Date();
const isExpired = (expiresAt: Date | number | undefined): boolean => {
if (typeof expiresAt === "number") {
return expiresAt <= 0;
}
if (expiresAt instanceof Date) {
return new Date(expiresAt) < new Date();
}
return false;
};
const getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for ";
@@ -26,31 +34,38 @@ const timeAgo = (inputDate: Date, currentDate: Date): string => {
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
if (elapsedYears > 0) {
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMonths > 0) {
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedWeeks > 0) {
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedDays > 0) {
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedHours > 0) {
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMinutes > 0) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
};
export const ShareSecretsRow = ({
@@ -82,29 +97,36 @@ export const ShareSecretsRow = ({
}, []);
useEffect(() => {
if (isExpired(row.expiresAt)) {
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id);
}
}, [isExpired(row.expiresAt)]);
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
return (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
<Td>
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
</Td>
<Td>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)}
<>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
</>
</Td>
<Td>
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
{row.expiresAfterViews}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
</Td>
<Td>
<IconButton
onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name,
name: "delete",
id: row.id
})
}
@@ -32,9 +32,13 @@ type Props = {
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets();
let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date())
let tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
const handleSecretExpiration = () => {
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date());
tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
};
return (
@@ -42,7 +46,7 @@ export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th>
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
<Th aria-label="button" />
</Tr>
</THead>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } from "react";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
@@ -21,58 +21,34 @@ export const ShareSecretPublicPage = () => {
}
}, [id, publicKey]);
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(id as string, hashedHex as string );
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
id as string,
hashedHex as string
);
const decryptedSecret = useMemo(() => {
if (data && data.encryptedValue && publicKey) {
const res = decryptSymmetric({
ciphertext: data.encryptedValue,
iv: data.iv,
tag: data.tag,
key,
key
});
return res;
}
return "";
}, [data, publicKey]);
const [timeLeft, setTimeLeft] = useState("");
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false,
initialState: false
});
const millisecondsPerDay = 1000 * 60 * 60 * 24;
const millisecondsPerHour = 1000 * 60 * 60;
const millisecondsPerMinute = 1000 * 60;
useEffect(() => {
const updateTimer = () => {
if (data && data.expiresAt) {
const expirationTime = new Date(data.expiresAt).getTime();
const currentTime = new Date().getTime();
const timeDifference = expirationTime - currentTime;
if (timeDifference < 0) {
setTimeLeft("Expired");
} else {
const hoursRemaining = Math.floor((timeDifference % millisecondsPerDay) / millisecondsPerHour);
const minutesRemaining = Math.floor((timeDifference % millisecondsPerHour) / millisecondsPerMinute);
const secondsRemaining = Math.floor((timeDifference % millisecondsPerMinute) / 1000);
setTimeLeft(`${hoursRemaining}h ${minutesRemaining}m ${secondsRemaining}s`);
}
}
};
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [data?.expiresAt]);
useEffect(() => {
if (isUrlCopied) {
setTimeout(() => setIsUrlCopied(false), 2000);
}
}, [isUrlCopied]);
const copyUrlToClipboard = () => {
navigator.clipboard.writeText(decryptedSecret);
setIsUrlCopied(true);
@@ -95,14 +71,12 @@ export const ShareSecretPublicPage = () => {
<DragonMainImage />
<div className="m-4 flex flex-1 flex-col items-center justify-start md:m-0">
<p className="mt-8 mb-2 text-xl font-semibold text-mineshaft-100 md:mt-20">
Secret Details
Shared Secret
</p>
<div className="mb-16 rounded-lg border border-mineshaft-600 bg-mineshaft-900 md:p-8">
<div className="mb-4 rounded-lg md:p-2">
<SecretTable
isLoading={isLoading}
sharedSecret={data}
decryptedSecret={decryptedSecret}
timeLeft={timeLeft}
isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard}
/>
@@ -1,96 +1,44 @@
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
SecretInput,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
import { EmptyState, IconButton, SecretInput, Td, Tr } from "@app/components/v2";
type Props = {
isLoading: boolean;
sharedSecret?: TViewSharedSecretResponse;
decryptedSecret: string;
timeLeft: string;
isUrlCopied: boolean;
copyUrlToClipboard: () => void;
};
export const SecretTable = ({
isLoading,
sharedSecret,
decryptedSecret,
timeLeft,
isUrlCopied,
copyUrlToClipboard
}: Props) => {
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Value</Th>
<Th>Valid Until</Th>
</Tr>
</THead>
<TBody>
{!isLoading && sharedSecret && decryptedSecret && (
<Tr key={sharedSecret.name}>
<Td>{sharedSecret.name}</Td>
<Td>
<div className="flex items-center md:space-x-2">
<div className="max-w-[20rem] flex-1 break-words">
<SecretInput
isVisible
value={decryptedSecret}
readOnly
/>
</div>
<IconButton
ariaLabel="copy to clipboard"
onClick={copyUrlToClipboard}
className="rounded p-2 hover:bg-gray-700"
size="xs"
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
</div>
</Td>
<Td>{timeLeft}</Td>
</Tr>
)}
{isLoading && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
Loading...
</Td>
</Tr>
)}
{!isLoading && !sharedSecret && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No such secret is shared yet!" icon={faKey} />
</Td>
</Tr>
)}
{!isLoading && sharedSecret && !decryptedSecret && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="Invalid URL to fetch the Secret!" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
}: Props) => (
<div className="flex items-center rounded border border-solid border-mineshaft-700 bg-mineshaft-800 p-4">
{isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
{!isLoading && !decryptedSecret && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
</Td>
</Tr>
)}
{!isLoading && decryptedSecret && (
<>
<div className="min-w-[12rem] max-w-[20rem] flex-1 break-words pr-4">
<SecretInput isVisible value={decryptedSecret} readOnly />
</div>
<IconButton
ariaLabel="copy to clipboard"
onClick={copyUrlToClipboard}
className="rounded p-2 hover:bg-gray-700"
size="xs"
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
</>
)}
</div>
);