mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: secret sharing
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
|
||||
3
frontend/src/hooks/api/secretSharing/index.ts
Normal file
3
frontend/src/hooks/api/secretSharing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
||||
35
frontend/src/hooks/api/secretSharing/mutations.ts
Normal file
35
frontend/src/hooks/api/secretSharing/mutations.ts
Normal 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"]);
|
||||
}
|
||||
});
|
||||
};
|
||||
32
frontend/src/hooks/api/secretSharing/queries.ts
Normal file
32
frontend/src/hooks/api/secretSharing/queries.ts
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
27
frontend/src/hooks/api/secretSharing/types.ts
Normal file
27
frontend/src/hooks/api/secretSharing/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
27
frontend/src/pages/project/[id]/secret-sharing/index.tsx
Normal file
27
frontend/src/pages/project/[id]/secret-sharing/index.tsx
Normal 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;
|
||||
24
frontend/src/pages/shared/secret/[id]/index.tsx
Normal file
24
frontend/src/pages/shared/secret/[id]/index.tsx
Normal 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;
|
||||
31
frontend/src/views/ShareSecretPage/ShareSecretPage.tsx
Normal file
31
frontend/src/views/ShareSecretPage/ShareSecretPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
1
frontend/src/views/ShareSecretPage/components/index.tsx
Normal file
1
frontend/src/views/ShareSecretPage/components/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ShareSecretSection } from "./ShareSecretSection";
|
||||
1
frontend/src/views/ShareSecretPage/index.tsx
Normal file
1
frontend/src/views/ShareSecretPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ShareSecretPage } from "./ShareSecretPage";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DragonMainImage } from "./MainImage";
|
||||
export { SecretTable } from "./SecretTable";
|
||||
1
frontend/src/views/ShareSecretPublicPage/index.tsx
Normal file
1
frontend/src/views/ShareSecretPublicPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ShareSecretPublicPage } from "./ShareSecretPublicPage";
|
||||
Reference in New Issue
Block a user