feat: secret sharing

This commit is contained in:
ShubhamPalriwala
2024-05-28 16:47:11 +05:30
parent 81b0c8bc12
commit 387981ea87
42 changed files with 1497 additions and 2 deletions

View File

@@ -224,6 +224,76 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps):
return plaintext;
};
/**
* Return new base64, NaCl, public-secret key pair for signing.
* @returns {Object} obj
* @returns {String} obj.publicKey - base64, NaCl, public key
* @returns {String} obj.secretKey - base64, NaCl, secret key
*/
const generateSignKeyPair = (): {
publicKey: string;
secretKey: string;
} => {
const pair = nacl.sign.keyPair();
return {
publicKey: nacl.util.encodeBase64(pair.publicKey),
secretKey: nacl.util.encodeBase64(pair.secretKey)
};
};
type SignAsymmetricProps = {
message: string;
privateKey: string;
};
/**
* Returns asymmetrically signed [message] using [privateKey]
* @param {Object} obj
* @param {String} obj.message - message to sign
* @param {String} obj.privateKey - base64-encoded private key
* @returns {String} signedMessage - base64-encoded signed message
*/
const signAssymmetric = ({ message, privateKey }: SignAsymmetricProps): string => {
let signedMessage;
try {
signedMessage = nacl.sign(nacl.util.decodeUTF8(message), nacl.util.decodeBase64(privateKey));
} catch (err) {
console.log("Failed to sign message", err);
process.exit(1);
}
return nacl.util.encodeBase64(signedMessage);
};
type OpenSignedAsymmetricProps = {
signedMessage: string;
publicKey: string;
};
/**
* Returns asymmetrically decrypted [message] using [publicKey]
* @param {Object} obj
* @param {String} obj.signedMessage - signed message to decrypt
* @param {String} obj.publicKey - base64-encoded public key
* @returns {String} signedMessage - base64-encoded decrypted message
*/
const openSignedAssymmetric = ({ signedMessage, publicKey }: OpenSignedAsymmetricProps): string => {
let originalMessage;
try {
originalMessage = nacl.sign.open(
nacl.util.decodeBase64(signedMessage),
nacl.util.decodeBase64(publicKey)
);
if (!originalMessage) {
throw new Error("Signature verification failed");
}
originalMessage = nacl.util.encodeUTF8(originalMessage);
} catch (err) {
console.log("Failed to verify signature", err);
}
return originalMessage;
};
export {
decryptAssymmetric,
decryptSymmetric,
@@ -231,5 +301,8 @@ export {
encryptAssymmetric,
encryptSymmetric,
generateKeyPair,
generateSignKeyPair,
openSignedAssymmetric,
signAssymmetric,
verifyPrivateKey
};

View File

@@ -23,7 +23,8 @@ export const publicPaths = [
"/login/provider/success", // TODO: change
"/login/provider/error", // TODO: change
"/login/sso",
"/admin/signup"
"/admin/signup",
"/shared/secret/[id]"
];
export const languageMap = {

View File

@@ -24,6 +24,7 @@ export enum ProjectPermissionSub {
SecretRollback = "secret-rollback",
SecretApproval = "secret-approval",
SecretRotation = "secret-rotation",
SecretSharing = "secret-sharing",
Identity = "identity"
}
@@ -51,6 +52,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions, ProjectPermissionSub.SecretSharing]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]

View File

@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
export const useCreateSharedSecret = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
return data;
},
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"])
});
};
export const useDeleteSharedSecret = () => {
const queryClient = useQueryClient();
return useMutation<
TSharedSecret,
{ message: string },
{ sharedSecretId: string; workspaceId: string }
>({
mutationFn: async ({ sharedSecretId, workspaceId }: TDeleteSharedSecretRequest) => {
const { data } = await apiRequest.delete<TSharedSecret>(
`/api/v1/secret-sharing/${workspaceId}/${sharedSecretId}`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries(["sharedSecrets"]);
}
});
};

View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSharedSecret, TViewSharedSecretResponse } from "./types";
export const useGetSharedSecrets = (workspaceId: string) => {
return useQuery({
queryKey: ["sharedSecrets"],
queryFn: async () => {
const { data } = await apiRequest.get<TSharedSecret[]>(
`/api/v1/secret-sharing/${workspaceId}`
);
return data;
}
});
};
export const useGetActiveSharedSecretById = (id: string) => {
return useQuery<TViewSharedSecretResponse, [string]>({
queryFn: async () => {
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/public/${id}`
);
return {
name: data.name,
signedValue: data.signedValue,
expiresAt: data.expiresAt
};
}
});
};

View File

@@ -0,0 +1,27 @@
export type TSharedSecret = {
id: string;
name: string;
signedValue: string;
userId: string;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
};
export type TCreateSharedSecretRequest = {
name: string;
signedValue: string;
expiresAt: Date;
workspaceId: string;
};
export type TViewSharedSecretResponse = {
name: string;
signedValue: string;
expiresAt: Date;
};
export type TDeleteSharedSecretRequest = {
sharedSecretId: string;
workspaceId: string;
};

View File

@@ -531,6 +531,18 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?.id}/secret-sharing`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?.id}/secret-sharing`
}
icon="system-outline-90-lock-closed"
>
Secret Sharing
</MenuItem>
</a>
</Link>
<Link href={`/integrations/${currentWorkspace?.id}`} passHref>
<a>
<MenuItem

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { ShareSecretPage } from "@app/views/ShareSecretPage";
const SecretApproval = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("approval.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("approval.og-title"))} />
<meta name="og:description" content={String(t("approval.og-description"))} />
</Head>
<div className="h-full">
<ShareSecretPage />
</div>
</>
);
};
export default SecretApproval;
SecretApproval.requireAuth = true;

View File

@@ -0,0 +1,24 @@
import Head from "next/head";
import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage";
const SecretApproval = () => {
return (
<>
<Head>
<title>Securely Share Secrets | Infisical</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="" />
<meta name="og:description" content="" />
</Head>
<div className="h-full">
<ShareSecretPublicPage />
</div>
</>
);
};
export default SecretApproval;
SecretApproval.requireAuth = false;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 }
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { ShareSecretSection } from "./ShareSecretSection";

View File

@@ -0,0 +1 @@
export { ShareSecretPage } from "./ShareSecretPage";

View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { openSignedAssymmetric } from "@app/components/utilities/cryptography/crypto";
import { useToggle } from "@app/hooks";
import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing";
import { DragonMainImage, SecretTable } from "./components";
export const ShareSecretPublicPage = () => {
const router = useRouter();
const { id, key: urlEncodedPublicKey } = router.query;
const publicKey = decodeURIComponent(urlEncodedPublicKey as string);
useEffect(() => {
if (!id || !publicKey) {
router.push("/404");
}
}, [id, publicKey]);
const { isLoading, data } = useGetActiveSharedSecretById(id as string);
const decryptedSecret = useMemo(() => {
if (data && data.signedValue && publicKey) {
const res = openSignedAssymmetric({
signedMessage: data.signedValue,
publicKey: publicKey as string
});
return res;
}
return "";
}, [data, publicKey]);
const [timeLeft, setTimeLeft] = useState("");
const [isUrlCopied, setIsUrlCopied] = useToggle(false);
useEffect(() => {
const updateTimer = () => {
if (data && data.expiresAt) {
const expiryDate = new Date(data.expiresAt).getTime();
const now = new Date().getTime();
const distance = expiryDate - now;
if (distance < 0) {
setTimeLeft("Expired");
} else {
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
setTimeLeft(`${hours}h ${minutes}m ${seconds}s`);
}
}
};
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [data?.expiresAt]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isUrlCopied) {
timer = setTimeout(() => setIsUrlCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isUrlCopied]);
const copyUrlToClipboard = () => {
navigator.clipboard.writeText(decryptedSecret as string);
setIsUrlCopied.on();
};
return (
<div className="flex flex-col justify-between bg-bunker-800 text-gray-200 md:h-screen">
<Head>
<title>Secret Shared | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<div className="my-4 flex justify-center md:my-8">
<Image src="/images/biglogo.png" height={180} width={240} alt="Infisical logo" />
</div>
<p className="mb-6 px-8 text-center text-xl md:px-0 md:text-3xl">
You’ve been shared a secret securely with Infisical.
</p>
<div className="flex min-h-screen w-full flex-col md:flex-row">
<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
</p>
<div className="mb-16 rounded-lg border border-mineshaft-600 bg-mineshaft-900 md:p-8">
<SecretTable
isLoading={isLoading}
sharedSecret={data}
decryptedSecret={decryptedSecret}
timeLeft={timeLeft}
isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard}
/>
</div>
<Link href="/">
<a className="mt-4 cursor-pointer rounded-md bg-mineshaft-500 py-2 px-4 text-lg font-semibold duration-200 hover:bg-primary hover:text-black">
Check Infisical out now!
</a>
</Link>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import Image from "next/image";
export const DragonMainImage = () => {
return (
<div className="hidden flex-1 flex-col items-center justify-center md:block md:items-start md:p-4">
<Image
src="/images/dragon-book.svg"
height={1000}
width={1413}
alt="Infisical Dragon - Came to send you a secret!"
/>
</div>
);
};

View File

@@ -0,0 +1,89 @@
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
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">{decryptedSecret}</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>
);
};

View File

@@ -0,0 +1,2 @@
export { DragonMainImage } from "./MainImage";
export { SecretTable } from "./SecretTable";

View File

@@ -0,0 +1 @@
export { ShareSecretPublicPage } from "./ShareSecretPublicPage";