Merge pull request #1905 from Infisical/shubham/eng-970-single-text-area-instead-of-key-and-value

feat: secret sharing supports expiry on view count
This commit is contained in:
Maidul Islam
2024-05-31 11:34:04 -04:00
committed by GitHub
20 changed files with 266 additions and 268 deletions
@@ -0,0 +1,33 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (!hasExpiresAfterViewsColumn) {
t.integer("expiresAfterViews");
}
if (hasSecretNameColumn) {
t.dropColumn("name");
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasExpiresAfterViewsColumn) {
t.dropColumn("expiresAfterViews");
}
if (!hasSecretNameColumn) {
t.string("name").notNullable();
}
});
}
+2 -2
View File
@@ -9,7 +9,6 @@ import { TImmutableDBKeys } from "./models";
export const SecretSharingSchema = z.object({ export const SecretSharingSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string(),
encryptedValue: z.string(), encryptedValue: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
@@ -18,7 +17,8 @@ export const SecretSharingSchema = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
expiresAfterViews: z.number().nullable().optional()
}); });
export type TSecretSharing = z.infer<typeof SecretSharingSchema>; export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
+2 -2
View File
@@ -23,8 +23,8 @@ export const UsersSchema = z.object({
isGhost: z.boolean().default(false), isGhost: z.boolean().default(false),
username: z.string(), username: z.string(),
isEmailVerified: z.boolean().default(false).nullable().optional(), isEmailVerified: z.boolean().default(false).nullable().optional(),
consecutiveFailedMfaAttempts: z.number().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
isLocked: z.boolean().optional(), isLocked: z.boolean().default(false).nullable().optional(),
temporaryLockDateEnd: z.date().nullable().optional() temporaryLockDateEnd: z.date().nullable().optional()
}); });
+1 -1
View File
@@ -128,7 +128,7 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
} }
if ($decr) { if ($decr) {
Object.entries($decr).forEach(([incrementField, incrementValue]) => { Object.entries($decr).forEach(([incrementField, incrementValue]) => {
void query.increment(incrementField, incrementValue); void query.decrement(incrementField, incrementValue);
}); });
} }
const [docs] = await query; const [docs] = await query;
@@ -45,7 +45,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
hashedHex: z.string() hashedHex: z.string()
}), }),
response: { response: {
200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true }) 200: SecretSharingSchema.pick({
encryptedValue: true,
iv: true,
tag: true,
expiresAt: true,
expiresAfterViews: true
})
} }
}, },
handler: async (req) => { handler: async (req) => {
@@ -55,11 +61,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
); );
if (!sharedSecret) return undefined; if (!sharedSecret) return undefined;
return { return {
name: sharedSecret.name,
encryptedValue: sharedSecret.encryptedValue, encryptedValue: sharedSecret.encryptedValue,
iv: sharedSecret.iv, iv: sharedSecret.iv,
tag: sharedSecret.tag, tag: sharedSecret.tag,
expiresAt: sharedSecret.expiresAt expiresAt: sharedSecret.expiresAt,
expiresAfterViews: sharedSecret.expiresAfterViews
}; };
} }
}); });
@@ -72,14 +78,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
schema: { schema: {
body: z.object({ body: z.object({
name: z.string(),
encryptedValue: z.string(), encryptedValue: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
hashedHex: z.string(), hashedHex: z.string(),
expiresAt: z.string().refine((date) => new Date(date) > new Date(), { expiresAt: z
message: "Expires at should be a future date" .string()
}) .refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
expiresAfterViews: z.number()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -89,19 +95,19 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body; const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
orgId: req.permission.orgId, orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
name,
encryptedValue, encryptedValue,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt: new Date(expiresAt) expiresAt: new Date(expiresAt),
expiresAfterViews
}); });
return { id: sharedSecret.id }; return { id: sharedSecret.id };
} }
@@ -16,17 +16,28 @@ export const secretSharingServiceFactory = ({
secretSharingDAL secretSharingDAL
}: TSecretSharingServiceFactoryDep) => { }: TSecretSharingServiceFactoryDep) => {
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } = const {
createSharedSecretInput; actor,
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); actorId,
if (!permission) throw new UnauthorizedError({ name: "User not in org" }); orgId,
const newSharedSecret = await secretSharingDAL.create({ actorAuthMethod,
name, actorOrgId,
encryptedValue, encryptedValue,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt, expiresAt,
expiresAfterViews
} = createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews,
userId: actorId, userId: actorId,
orgId orgId
}); });
@@ -43,9 +54,16 @@ export const secretSharingServiceFactory = ({
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
if (sharedSecret && sharedSecret.expiresAt < new Date()) { if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return; return;
} }
if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) {
if (sharedSecret.expiresAfterViews === 0) {
await secretSharingDAL.deleteById(sharedSecretId);
return;
}
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
}
return sharedSecret; return sharedSecret;
}; };
@@ -9,12 +9,12 @@ export type TSharedSecretPermission = {
}; };
export type TCreateSharedSecretDTO = { export type TCreateSharedSecretDTO = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
hashedHex: string; hashedHex: string;
expiresAt: Date; expiresAt: Date;
expiresAfterViews: number;
} & TSharedSecretPermission; } & TSharedSecretPermission;
export type TDeleteSharedSecretDTO = { export type TDeleteSharedSecretDTO = {
+9 -10
View File
@@ -1,37 +1,36 @@
--- ---
title: "Secret Sharing" title: "Secret Sharing"
sidebarTitle: "Secret Sharing" sidebarTitle: "Secret Sharing"
description: "Learn how to share time-bound secrets securely with anyone on the internet." description: "Learn how to share time & view-count bound secrets securely with anyone on the internet."
--- ---
Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse.
Infisical offers a secure solution for sharing secrets over the internet in a time-bound manner. Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner.
With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself.
## Share a Secret ## Share a Secret
1. Navigate to the **Projects** page. 1. Navigate to the **Organization** page.
2. Click on the **Secret Sharing** tab from the sidebar. 2. Click on the **Secret Sharing** tab from the sidebar.
![Secret Sharing](../../images/platform/secret-sharing/overview.png) ![Secret Sharing](../../images/platform/secret-sharing/overview.png)
3. Click on the **Share Secret** button.
<Note> <Note>
Infisical does not have access to the shared secrets. This is a part of our zero Infisical does not have access to the shared secrets. This is a part of our
knowledge architecture. zero knowledge architecture.
</Note> </Note>
4. Enter the secret you want to share and set the expiration time. Click on the **Share Secret** button. 3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met.
![Add Sharing Secret](../../images/platform/secret-sharing/new-secret.png) ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png)
<Note> <Note>
Secret once set cannot be changed. This is to ensure that the secret is not Secret once set cannot be changed. This is to ensure that the secret is not
tampered with. tampered with.
</Note> </Note>
5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration time. Hence, it is recommended to share the link only with the intended recipient. 5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient.
![Copy URL](../../images/platform/secret-sharing/copy-url.png) ![Copy URL](../../images/platform/secret-sharing/copy-url.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 467 KiB

@@ -8,9 +8,7 @@ export const useGetSharedSecrets = () => {
return useQuery({ return useQuery({
queryKey: ["sharedSecrets"], queryKey: ["sharedSecrets"],
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<TSharedSecret[]>( const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/");
"/api/v1/secret-sharing/"
);
return data; return data;
} }
}); });
@@ -23,11 +21,9 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex:
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
); );
return { return {
name: data.name,
encryptedValue: data.encryptedValue, encryptedValue: data.encryptedValue,
iv: data.iv, iv: data.iv,
tag: data.tag, tag: data.tag,
expiresAt: data.expiresAt
}; };
} }
}); });
+3 -10
View File
@@ -1,31 +1,24 @@
export type TSharedSecret = { export type TSharedSecret = {
id: string; id: string;
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
userId: string; userId: string;
expiresAt: Date; orgId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; } & TCreateSharedSecretRequest;
export type TCreateSharedSecretRequest = { export type TCreateSharedSecretRequest = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
hashedHex: string; hashedHex: string;
expiresAt: Date; expiresAt: Date;
expiresAfterViews: number;
}; };
export type TViewSharedSecretResponse = { export type TViewSharedSecretResponse = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
expiresAt: Date;
}; };
export type TDeleteSharedSecretRequest = { export type TDeleteSharedSecretRequest = {
@@ -13,14 +13,16 @@ export const ShareSecretPage = () => {
<p className="text-bunker-300">Share secrets securely using a shareable link</p> <p className="text-bunker-300">Share secrets securely using a shareable link</p>
</div> </div>
<div className="flex w-max justify-center"> <div className="flex w-max justify-center">
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing"> <Link href="https://infisical.com/docs/documentation/platform/secret-sharing" passHref>
<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"> <a target="_blank" rel="noopener noreferrer">
<div className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "} Documentation{" "}
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs" className="mb-[0.06rem] ml-1 text-xs"
/> />
</span> </div>
</a>
</Link> </Link>
</div> </div>
</div> </div>
@@ -9,9 +9,7 @@ import { AxiosError } from "axios";
import * as yup from "yup"; import * as yup from "yup";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
encryptSymmetric,
} from "@app/components/utilities/cryptography/crypto";
import { import {
Button, Button,
FormControl, FormControl,
@@ -49,22 +47,12 @@ const expirationUnitsAndActions = [
unit: "Weeks", unit: "Weeks",
action: (expiresAt: Date, expiresInValue: number) => action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) 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({ const schema = yup.object({
name: yup.string().max(100).required().label("Shared Secret Name"), value: yup.string().max(10000).required().label("Shared Secret Value"),
value: yup.string().max(1000).required().label("Shared Secret Value"), expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"), expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit") expiresInUnit: yup.string().required().label("Expiration Unit")
}); });
@@ -93,7 +81,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const [newSharedSecret, setnewSharedSecret] = useState(""); const [newSharedSecret, setnewSharedSecret] = useState("");
const hasSharedSecret = Boolean(newSharedSecret); const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({ const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false, initialState: false
}); });
const copyUrlToClipboard = () => { const copyUrlToClipboard = () => {
@@ -106,10 +94,14 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
}, [isUrlCopied]); }, [isUrlCopied]);
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => { const onFormSubmit = async ({
value,
expiresInValue,
expiresInUnit,
expiresAfterViews
}: FormData) => {
try { try {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
const key = crypto.randomBytes(16).toString("hex"); const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({ const { ciphertext, iv, tag } = encryptSymmetric({
@@ -117,25 +109,26 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
key key
}); });
const expiresAt = new Date(); const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find( const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit (item) => item.unit === expiresInUnit
)?.action; )?.action;
if (updateExpiresAt) { if (updateExpiresAt && expiresInValue) {
updateExpiresAt(expiresAt, expiresInValue); updateExpiresAt(expiresAt, expiresInValue);
} }
const { id } = await createSharedSecret.mutateAsync({ const { id } = await createSharedSecret.mutateAsync({
name,
encryptedValue: ciphertext, encryptedValue: ciphertext,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt, expiresAt,
expiresAfterViews
}); });
setnewSharedSecret( setnewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}` `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
); );
createNotification({ createNotification({
@@ -168,90 +161,100 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
setnewSharedSecret(""); setnewSharedSecret("");
}} }}
> >
<ModalContent <ModalContent
title="Share a Secret" title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. " subTitle="This link is only accessible once. Please share this link with intended recipients. "
> >
{!hasSharedSecret ? ( {!hasSharedSecret ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <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 <Controller
control={control} control={control}
name="value" name="value"
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Shared Secret Value" label="Shared Secret"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<SecretInput <SecretInput
isVisible isVisible
{...field} {...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> </FormControl>
)} )}
/> />
<div className="flex w-full flex-row justify-end"> <div className="flex w-full flex-row">
<div className="w-3/5"> <div className="w-2/7 flex">
<Controller <Controller
control={control} control={control}
name="expiresInValue" name="expiresAfterViews"
defaultValue={1} defaultValue={6}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Expiration Value" className="mb-4 w-full"
label="Expires After Views"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText="Please enter a valid number of views"
> >
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={1} />
</FormControl> </FormControl>
)} )}
/> />
</div> </div>
<div className="w-2/5 pl-4"> <div className="w-1/7 flex items-center justify-center px-2">
<Controller <p className="px-4 text-sm text-gray-400">OR</p>
control={control} </div>
name="expiresInUnit" <div className="w-4/7 flex">
defaultValue={expirationUnitsAndActions[0].unit} <div className="flex w-full">
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( <div className="flex w-2/5 w-full justify-center">
<FormControl <Controller
label="Expiration Unit" control={control}
errorText={error?.message} name="expiresInValue"
isError={Boolean(error)} defaultValue={6}
> render={({ field, fieldState: { error } }) => (
<Select <FormControl
defaultValue={field.value} label="Expires after Time"
{...field} isError={Boolean(error)}
onValueChange={(e) => onChange(e)} errorText="Please enter a valid time duration"
className="w-full" >
> <Input {...field} type="number" min={0} />
{expirationUnitsAndActions.map(({ unit }) => ( </FormControl>
<SelectItem value={unit} key={unit}> )}
{unit} />
</SelectItem> </div>
))} <div className="flex w-3/5 w-full justify-center">
</Select> <Controller
</FormControl> control={control}
)} name="expiresInUnit"
/> defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
</div> </div>
</div> </div>
<div className="mt-8 flex items-center"> <div className="flex items-center">
<Button <Button
className="mr-4" className="mr-4"
type="submit" type="submit"
@@ -8,7 +8,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : ""); 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 => const getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for "; isExpired(expiresAt) ? "Expired " : "Valid for ";
@@ -26,31 +34,38 @@ const timeAgo = (inputDate: Date, currentDate: Date): string => {
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365)); const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
if (elapsedYears > 0) { 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) { 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) { 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) { 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) { 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) { if (elapsedMinutes > 0) {
return `${elapsedMinutes} minute${elapsedMinutes === 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"
}`; }`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}; };
export const ShareSecretsRow = ({ export const ShareSecretsRow = ({
@@ -82,29 +97,36 @@ export const ShareSecretsRow = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isExpired(row.expiresAt)) { if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id); onSecretExpiration(row.id);
} }
}, [isExpired(row.expiresAt)]); }, [isExpired(row.expiresAt || row.expiresAfterViews)]);
return ( return (
<Tr key={row.id}> <Tr key={row.id}>
<Td>{row.name}</Td> <Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
<Td> <Td>
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p> <p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p> <p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
</Td> </Td>
<Td> <Td>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}> <>
{getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)} <p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
</>
</Td>
<Td>
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
{row.expiresAfterViews}
</p> </p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
</Td> </Td>
<Td> <Td>
<IconButton <IconButton
onClick={() => onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", { handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name, name: "delete",
id: row.id id: row.id
}) })
} }
@@ -32,9 +32,13 @@ type Props = {
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets(); const { isLoading, data = [] } = useGetSharedSecrets();
let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()) let tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
const handleSecretExpiration = () => { const handleSecretExpiration = () => {
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()); tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
}; };
return ( return (
@@ -42,7 +46,7 @@ export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
<Th aria-label="button" /> <Th aria-label="button" />
</Tr> </Tr>
</THead> </THead>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
import Head from "next/head"; import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -21,58 +21,34 @@ export const ShareSecretPublicPage = () => {
} }
}, [id, publicKey]); }, [id, publicKey]);
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(id as string, hashedHex as string ); const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
id as string,
hashedHex as string
);
const decryptedSecret = useMemo(() => { const decryptedSecret = useMemo(() => {
if (data && data.encryptedValue && publicKey) { if (data && data.encryptedValue && publicKey) {
const res = decryptSymmetric({ const res = decryptSymmetric({
ciphertext: data.encryptedValue, ciphertext: data.encryptedValue,
iv: data.iv, iv: data.iv,
tag: data.tag, tag: data.tag,
key, key
}); });
return res; return res;
} }
return ""; return "";
}, [data, publicKey]); }, [data, publicKey]);
const [timeLeft, setTimeLeft] = useState("");
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({ const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false, initialState: false
}); });
const millisecondsPerDay = 1000 * 60 * 60 * 24;
const millisecondsPerHour = 1000 * 60 * 60;
const millisecondsPerMinute = 1000 * 60;
useEffect(() => {
const updateTimer = () => {
if (data && data.expiresAt) {
const expirationTime = new Date(data.expiresAt).getTime();
const currentTime = new Date().getTime();
const timeDifference = expirationTime - currentTime;
if (timeDifference < 0) {
setTimeLeft("Expired");
} else {
const hoursRemaining = Math.floor((timeDifference % millisecondsPerDay) / millisecondsPerHour);
const minutesRemaining = Math.floor((timeDifference % millisecondsPerHour) / millisecondsPerMinute);
const secondsRemaining = Math.floor((timeDifference % millisecondsPerMinute) / 1000);
setTimeLeft(`${hoursRemaining}h ${minutesRemaining}m ${secondsRemaining}s`);
}
}
};
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [data?.expiresAt]);
useEffect(() => { useEffect(() => {
if (isUrlCopied) { if (isUrlCopied) {
setTimeout(() => setIsUrlCopied(false), 2000); setTimeout(() => setIsUrlCopied(false), 2000);
} }
}, [isUrlCopied]); }, [isUrlCopied]);
const copyUrlToClipboard = () => { const copyUrlToClipboard = () => {
navigator.clipboard.writeText(decryptedSecret); navigator.clipboard.writeText(decryptedSecret);
setIsUrlCopied(true); setIsUrlCopied(true);
@@ -95,14 +71,12 @@ export const ShareSecretPublicPage = () => {
<DragonMainImage /> <DragonMainImage />
<div className="m-4 flex flex-1 flex-col items-center justify-start md:m-0"> <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"> <p className="mt-8 mb-2 text-xl font-semibold text-mineshaft-100 md:mt-20">
Secret Details Shared Secret
</p> </p>
<div className="mb-16 rounded-lg border border-mineshaft-600 bg-mineshaft-900 md:p-8"> <div className="mb-4 rounded-lg md:p-2">
<SecretTable <SecretTable
isLoading={isLoading} isLoading={isLoading}
sharedSecret={data}
decryptedSecret={decryptedSecret} decryptedSecret={decryptedSecret}
timeLeft={timeLeft}
isUrlCopied={isUrlCopied} isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard} copyUrlToClipboard={copyUrlToClipboard}
/> />
@@ -1,96 +1,44 @@
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { EmptyState, IconButton, SecretInput, Td, Tr } from "@app/components/v2";
EmptyState,
IconButton,
SecretInput,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
type Props = { type Props = {
isLoading: boolean; isLoading: boolean;
sharedSecret?: TViewSharedSecretResponse;
decryptedSecret: string; decryptedSecret: string;
timeLeft: string;
isUrlCopied: boolean; isUrlCopied: boolean;
copyUrlToClipboard: () => void; copyUrlToClipboard: () => void;
}; };
export const SecretTable = ({ export const SecretTable = ({
isLoading, isLoading,
sharedSecret,
decryptedSecret, decryptedSecret,
timeLeft,
isUrlCopied, isUrlCopied,
copyUrlToClipboard copyUrlToClipboard
}: Props) => { }: Props) => (
return ( <div className="flex items-center rounded border border-solid border-mineshaft-700 bg-mineshaft-800 p-4">
<TableContainer> {isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
<Table> {!isLoading && !decryptedSecret && (
<THead> <Tr>
<Tr> <Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<Th>Name</Th> <EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
<Th>Value</Th> </Td>
<Th>Valid Until</Th> </Tr>
</Tr> )}
</THead> {!isLoading && decryptedSecret && (
<TBody> <>
{!isLoading && sharedSecret && decryptedSecret && ( <div className="min-w-[12rem] max-w-[20rem] flex-1 break-words pr-4">
<Tr key={sharedSecret.name}> <SecretInput isVisible value={decryptedSecret} readOnly />
<Td>{sharedSecret.name}</Td> </div>
<Td> <IconButton
<div className="flex items-center md:space-x-2"> ariaLabel="copy to clipboard"
<div className="max-w-[20rem] flex-1 break-words"> onClick={copyUrlToClipboard}
<SecretInput className="rounded p-2 hover:bg-gray-700"
isVisible size="xs"
value={decryptedSecret} >
readOnly <FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
/> </IconButton>
</div> </>
<IconButton )}
ariaLabel="copy to clipboard" </div>
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>
);
};