feat: secret sharing

This commit is contained in:
ShubhamPalriwala
2024-05-29 14:10:46 +05:30
parent 81b0c8bc12
commit 387981ea87
42 changed files with 1497 additions and 2 deletions
@@ -0,0 +1,31 @@
import Link from "next/link";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ShareSecretSection } from "./components";
export const ShareSecretPage = () => {
return (
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
<div className="flex items-center justify-between py-6">
<div className="flex w-full flex-col">
<h2 className="text-3xl font-semibold text-gray-200">Secret Sharing</h2>
<p className="text-bunker-300">Share secrets with anybody securely and efficiently</p>
</div>
<div className="flex w-max justify-center">
{/* Add docs here */}
<Link href="https://infisical.com/docs/documentation/platform/pr-workflows">
<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">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
</div>
<ShareSecretSection />
</div>
);
};
@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { AxiosError } from "axios";
import * as yup from "yup";
import { createNotification } from "@app/components/notifications";
import {
generateSignKeyPair,
signAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateSharedSecret } from "@app/hooks/api/secretSharing";
import { UsePopUpState } from "@app/hooks/usePopUp";
const expirationUnitsAndActions = [
{
unit: "Minutes",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue)
},
{
unit: "Hours",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setHours(expiresAt.getHours() + expiresInValue)
},
{
unit: "Days",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue)
},
{
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"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit")
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["createSharedSecret"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["createSharedSecret"]>,
state?: boolean
) => void;
};
export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const createSharedSecret = useCreateSharedSecret();
const { currentWorkspace } = useWorkspace();
const [newSharedSecret, setnewSharedSecret] = useState("");
const [isUrlCopied, setIsUrlCopied] = useToggle(false);
const hasSharedSecret = Boolean(newSharedSecret);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isUrlCopied) {
timer = setTimeout(() => setIsUrlCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isUrlCopied]);
const copyUrlToClipboard = () => {
navigator.clipboard.writeText(newSharedSecret);
setIsUrlCopied.on();
};
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => {
try {
if (!currentWorkspace?.id) return;
const signingKeyPair = generateSignKeyPair();
const signedMessage = signAssymmetric({
message: value,
privateKey: signingKeyPair.secretKey
});
const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit
)?.action;
if (updateExpiresAt) {
updateExpiresAt(expiresAt, expiresInValue);
}
const { id } = await createSharedSecret.mutateAsync({
name,
signedValue: signedMessage,
expiresAt,
workspaceId: currentWorkspace.id
});
setnewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
signingKeyPair.publicKey
)}`
);
createNotification({
text: "Successfully created a shared secret",
type: "success"
});
} catch (err) {
console.error(err);
const axiosError = err as AxiosError;
if (axiosError?.response?.status === 401) {
createNotification({
text: "You do not have access to create shared secrets",
type: "error"
});
} else {
createNotification({
text: "Failed to create a shared secret",
type: "error"
});
}
}
};
return (
<Modal
isOpen={popUp?.createSharedSecret?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createSharedSecret", open);
reset();
setnewSharedSecret("");
}}
>
<ModalContent
title="Share a secret with anybody on the internet"
subTitle="When a secret is shared, you will only see the public share URL once before it disappears. Make sure to save it somewhere."
>
{!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"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your secret value" />
</FormControl>
)}
/>
<div className="flex w-full flex-row justify-end">
<div className="w-3/5">
<Controller
control={control}
name="expiresInValue"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expiration Value"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your secret value" />
</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>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newSharedSecret}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyUrlToClipboard}
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to Copy
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,108 @@
import { useState } from "react";
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, Checkbox, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing";
import { AddShareSecretModal } from "./AddShareSecretModal";
import { ShareSecretsTable } from "./ShareSecretsTable";
type DeleteModalData = { name: string; id: string };
export const ShareSecretSection = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const deleteSharedSecret = useDeleteSharedSecret();
const [showExpiredSharedSecrets, setShowExpiredSharedSecrets] = useState(false);
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"createSharedSecret",
"deleteSharedSecretConfirmation"
] as const);
const onDeleteApproved = async () => {
try {
if (!currentWorkspace?.id) return;
deleteSharedSecret.mutateAsync({
sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id,
workspaceId: currentWorkspace.id
});
createNotification({
text: "Successfully deleted shared secret",
type: "success"
});
handlePopUpClose("deleteSharedSecretConfirmation");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete shared secret",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretSharing}
>
{(isAllowed) => (
<Button
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("createSharedSecret");
}}
isDisabled={!isAllowed}
>
Share Secret
</Button>
)}
</ProjectPermissionCan>
</div>
<div className="mb-8 flex items-center justify-between">
<p className="flex-grow text-gray-400">
Every secret shared can be accessed with the URL (shown during creation) before its
expiry.
</p>
<Checkbox
className="shrink-0 data-[state=checked]:bg-primary"
id="showInactive"
isChecked={showExpiredSharedSecrets}
onCheckedChange={(state) => {
setShowExpiredSharedSecrets(state as boolean);
}}
>
Show expired shared secrets too
</Checkbox>
</div>
<ShareSecretsTable
handlePopUpOpen={handlePopUpOpen}
showExpiredSharedSecrets={showExpiredSharedSecrets}
/>
<AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name || " "
} shared secret?`}
onChange={(isOpen) => handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)}
deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretSharing }
);
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Td, Tr } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { TSharedSecret } from "@app/hooks/api/secretSharing";
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 getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for ";
const timeAgo = (inputDate: Date, currentDate: Date): string => {
const now = new Date(currentDate).getTime();
const date = new Date(inputDate).getTime();
const elapsedMilliseconds = now - date;
const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000));
const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60));
const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60));
const elapsedDays = Math.abs(Math.floor(elapsedHours / 24));
const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7));
const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30));
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
console.log(
elapsedYears,
elapsedMonths,
elapsedWeeks,
elapsedDays,
elapsedHours,
elapsedMinutes,
elapsedSeconds
);
if (elapsedYears > 0) {
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"
}`;
}
if (elapsedWeeks > 0) {
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"
}`;
}
if (elapsedHours > 0) {
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"
}`;
};
export const ShareSecretsRow = ({
row,
handlePopUpOpen,
onSecretExpiration
}: {
row: TSharedSecret;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
onSecretExpiration: (expiredSecretId: string) => void;
}) => {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const intervalId = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(intervalId);
}, []);
useEffect(() => {
if (isExpired(row.expiresAt)) {
onSecretExpiration(row.id);
}
}, [isExpired(row.expiresAt)]);
return (
<Tr key={row.id}>
<Td>{row.name}</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>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretSharing}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name,
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
};
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { faKey } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TSharedSecret, useGetSharedSecrets } from "@app/hooks/api/secretSharing";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { ShareSecretsRow } from "./ShareSecretsRow";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
showExpiredSharedSecrets: boolean;
};
export const ShareSecretsTable = ({ handlePopUpOpen, showExpiredSharedSecrets }: Props) => {
const [tableData, setTableData] = useState<TSharedSecret[]>([]);
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { isLoading, data = [] } = useGetSharedSecrets(workspaceId);
useEffect(() => {
if (!isLoading) {
if (!showExpiredSharedSecrets) {
setTableData(data.filter((secret) => new Date(secret.expiresAt) > new Date()));
} else {
setTableData(data);
}
}
}, [isLoading, data, showExpiredSharedSecrets]);
const handleSecretExpiration = () => {
if (!showExpiredSharedSecrets) {
setTableData(
data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date())
);
}
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />}
{!isLoading &&
tableData &&
tableData.map((row) => (
<ShareSecretsRow
key={row.id}
row={row}
handlePopUpOpen={handlePopUpOpen}
onSecretExpiration={handleSecretExpiration}
/>
))}
{!isLoading && tableData && tableData?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No secrets shared yet!" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1 @@
export { ShareSecretSection } from "./ShareSecretSection";
@@ -0,0 +1 @@
export { ShareSecretPage } from "./ShareSecretPage";