feat: secret sharing supports expiry on view count + multi-line secret value

This commit is contained in:
ShubhamPalriwala
2024-05-31 00:00:48 +05:30
parent c097c918ed
commit 3438dbc70d
10 changed files with 251 additions and 130 deletions

View File

@@ -1,31 +1,26 @@
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;
expiresAt?: Date;
expiresAfterViews?: number;
};
export type TViewSharedSecretResponse = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
expiresAt: Date;
expiresAt?: Date;
expiresAfterViews?: number;
};
export type TDeleteSharedSecretRequest = {

View File

@@ -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,
@@ -22,7 +20,8 @@ import {
ModalContent,
SecretInput,
Select,
SelectItem
SelectItem,
Switch
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useTimedReset } from "@app/hooks";
@@ -63,10 +62,10 @@ const expirationUnitsAndActions = [
];
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")
expiresAfterViews: yup.number().min(1).optional().label("Expires After Views"),
expiresInValue: yup.number().min(1).optional().label("Expiration Value"),
expiresInUnit: yup.string().optional().label("Expiration Unit")
});
export type FormData = yup.InferType<typeof schema>;
@@ -91,9 +90,10 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const createSharedSecret = useCreateSharedSecret();
const { currentOrg } = useOrganization();
const [newSharedSecret, setnewSharedSecret] = useState("");
const [expiryOption, setExpiryOption] = useState<"time" | "views">("time");
const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false,
initialState: false
});
const copyUrlToClipboard = () => {
@@ -106,10 +106,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 +121,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,
expiresAt: expiryOption === "time" ? expiresAt : undefined,
expiresAfterViews: expiryOption === "views" ? expiresAfterViews : undefined
});
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 +173,114 @@ 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>
<p className="mb-2 flex items-center text-sm font-normal text-mineshaft-400">
Set Expiry Based On
</p>
<div className="mb-4 flex w-full flex-row justify-start">
<p className="mb-0.5 mr-1 flex items-center text-sm font-normal text-mineshaft-400">
Time
</p>
<Switch
id="expiryOption"
onCheckedChange={(value) => setExpiryOption(value ? "views" : "time")}
isChecked={expiryOption === "views"}
/>
<p className="mb-0.5 ml-3 flex items-center text-sm font-normal text-mineshaft-400">
Views
</p>
</div>
</div>
<div>
{expiryOption === "views" ? (
<Controller
control={control}
name="expiresInValue"
name="expiresAfterViews"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expiration Value"
className="mb-4 w-full"
label="Expires After Views"
isError={Boolean(error)}
errorText={error?.message}
>
<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>
) : (
<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} type="number" min={0} />
</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>
<div className="mt-8 flex items-center">
<div className="flex items-center">
<Button
className="mr-4"
type="submit"

View File

@@ -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,41 @@ 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>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
{row.expiresAfterViews ? (
<p
className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}
>
Valid for {row.expiresAfterViews} more views
</p>
) : (
<>
<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>
<IconButton
onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name,
name: "delete",
id: row.id
})
}

View File

@@ -32,9 +32,17 @@ 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) =>
(secret.expiresAt && new Date(secret.expiresAt) > new Date()) ||
(secret.expiresAfterViews && secret.expiresAfterViews > 0)
);
const handleSecretExpiration = () => {
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date());
tableData = data.filter(
(secret) =>
(secret.expiresAt && new Date(secret.expiresAt) > new Date()) ||
(secret.expiresAfterViews && secret.expiresAfterViews > 0)
);
};
return (
@@ -42,7 +50,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 aria-label="button" />
</Tr>
</THead>