mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SecretSharingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
@@ -18,7 +17,8 @@ export const SecretSharingSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
orgId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
expiresAfterViews: z.number().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||
|
||||
@@ -23,8 +23,8 @@ export const UsersSchema = z.object({
|
||||
isGhost: z.boolean().default(false),
|
||||
username: z.string(),
|
||||
isEmailVerified: z.boolean().default(false).nullable().optional(),
|
||||
consecutiveFailedMfaAttempts: z.number().optional(),
|
||||
isLocked: z.boolean().optional(),
|
||||
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
|
||||
isLocked: z.boolean().default(false).nullable().optional(),
|
||||
temporaryLockDateEnd: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
|
||||
}
|
||||
if ($decr) {
|
||||
Object.entries($decr).forEach(([incrementField, incrementValue]) => {
|
||||
void query.increment(incrementField, incrementValue);
|
||||
void query.decrement(incrementField, incrementValue);
|
||||
});
|
||||
}
|
||||
const [docs] = await query;
|
||||
|
||||
@@ -45,7 +45,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
hashedHex: z.string()
|
||||
}),
|
||||
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) => {
|
||||
@@ -55,11 +61,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
);
|
||||
if (!sharedSecret) return undefined;
|
||||
return {
|
||||
name: sharedSecret.name,
|
||||
encryptedValue: sharedSecret.encryptedValue,
|
||||
iv: sharedSecret.iv,
|
||||
tag: sharedSecret.tag,
|
||||
expiresAt: sharedSecret.expiresAt
|
||||
expiresAt: sharedSecret.expiresAt,
|
||||
expiresAfterViews: sharedSecret.expiresAfterViews
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -72,14 +78,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.string().refine((date) => new Date(date) > new Date(), {
|
||||
message: "Expires at should be a future date"
|
||||
})
|
||||
expiresAt: z
|
||||
.string()
|
||||
.refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
|
||||
expiresAfterViews: z.number()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -89,19 +95,19 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
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({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
name,
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt)
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews
|
||||
});
|
||||
return { id: sharedSecret.id };
|
||||
}
|
||||
|
||||
@@ -16,17 +16,28 @@ export const secretSharingServiceFactory = ({
|
||||
secretSharingDAL
|
||||
}: TSecretSharingServiceFactoryDep) => {
|
||||
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
|
||||
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } =
|
||||
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({
|
||||
name,
|
||||
const {
|
||||
actor,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
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,
|
||||
orgId
|
||||
});
|
||||
@@ -43,9 +54,16 @@ export const secretSharingServiceFactory = ({
|
||||
|
||||
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
|
||||
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
|
||||
if (sharedSecret && sharedSecret.expiresAt < new Date()) {
|
||||
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ export type TSharedSecretPermission = {
|
||||
};
|
||||
|
||||
export type TCreateSharedSecretDTO = {
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number;
|
||||
} & TSharedSecretPermission;
|
||||
|
||||
export type TDeleteSharedSecretDTO = {
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
---
|
||||
title: "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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
3. Click on the **Share Secret** button.
|
||||
|
||||
<Note>
|
||||
Infisical does not have access to the shared secrets. This is a part of our zero
|
||||
knowledge architecture.
|
||||
Infisical does not have access to the shared secrets. This is a part of our
|
||||
zero knowledge architecture.
|
||||
</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.
|
||||
|
||||

|
||||

|
||||
|
||||
<Note>
|
||||
Secret once set cannot be changed. This is to ensure that the secret is not
|
||||
tampered with.
|
||||
</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.
|
||||
|
||||

|
||||
|
||||
|
||||
BIN
docs/images/platform/secret-sharing/create-new-secret.png
Normal file
BIN
docs/images/platform/secret-sharing/create-new-secret.png
Normal file
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({
|
||||
queryKey: ["sharedSecrets"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TSharedSecret[]>(
|
||||
"/api/v1/secret-sharing/"
|
||||
);
|
||||
const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
@@ -23,11 +21,9 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex:
|
||||
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
|
||||
);
|
||||
return {
|
||||
name: data.name,
|
||||
encryptedValue: data.encryptedValue,
|
||||
iv: data.iv,
|
||||
tag: data.tag,
|
||||
expiresAt: data.expiresAt
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
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;
|
||||
expiresAfterViews: number;
|
||||
};
|
||||
|
||||
export type TViewSharedSecretResponse = {
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type TDeleteSharedSecretRequest = {
|
||||
|
||||
@@ -13,14 +13,16 @@ export const ShareSecretPage = () => {
|
||||
<p className="text-bunker-300">Share secrets securely using a shareable link</p>
|
||||
</div>
|
||||
<div className="flex w-max justify-center">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing">
|
||||
<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">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing" passHref>
|
||||
<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{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -49,22 +47,12 @@ const expirationUnitsAndActions = [
|
||||
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"),
|
||||
value: yup.string().max(10000).required().label("Shared Secret Value"),
|
||||
expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
|
||||
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
|
||||
expiresInUnit: yup.string().required().label("Expiration Unit")
|
||||
});
|
||||
@@ -93,7 +81,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [newSharedSecret, setnewSharedSecret] = useState("");
|
||||
const hasSharedSecret = Boolean(newSharedSecret);
|
||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
||||
initialState: false,
|
||||
initialState: false
|
||||
});
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
@@ -106,10 +94,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 +109,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,
|
||||
expiresAfterViews
|
||||
});
|
||||
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 +161,100 @@ 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 className="flex w-full flex-row">
|
||||
<div className="w-2/7 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresInValue"
|
||||
defaultValue={1}
|
||||
name="expiresAfterViews"
|
||||
defaultValue={6}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expiration Value"
|
||||
className="mb-4 w-full"
|
||||
label="Expires After Views"
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</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 className="w-1/7 flex items-center justify-center px-2">
|
||||
<p className="px-4 text-sm text-gray-400">OR</p>
|
||||
</div>
|
||||
<div className="w-4/7 flex">
|
||||
<div className="flex w-full">
|
||||
<div className="flex w-2/5 w-full justify-center">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresInValue"
|
||||
defaultValue={6}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expires after Time"
|
||||
isError={Boolean(error)}
|
||||
errorText="Please enter a valid time duration"
|
||||
>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-3/5 w-full justify-center">
|
||||
<Controller
|
||||
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 className="mt-8 flex items-center">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
|
||||
@@ -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,36 @@ 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 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 className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteSharedSecretConfirmation", {
|
||||
name: row.name,
|
||||
name: "delete",
|
||||
id: row.id
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,9 +32,13 @@ 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) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
|
||||
);
|
||||
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 (
|
||||
@@ -42,7 +46,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>Views Left</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -21,58 +21,34 @@ export const ShareSecretPublicPage = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (data && data.encryptedValue && publicKey) {
|
||||
const res = decryptSymmetric({
|
||||
ciphertext: data.encryptedValue,
|
||||
iv: data.iv,
|
||||
tag: data.tag,
|
||||
key,
|
||||
key
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return "";
|
||||
}, [data, publicKey]);
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState("");
|
||||
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(() => {
|
||||
if (isUrlCopied) {
|
||||
setTimeout(() => setIsUrlCopied(false), 2000);
|
||||
}
|
||||
}, [isUrlCopied]);
|
||||
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
navigator.clipboard.writeText(decryptedSecret);
|
||||
setIsUrlCopied(true);
|
||||
@@ -95,14 +71,12 @@ export const ShareSecretPublicPage = () => {
|
||||
<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
|
||||
Shared Secret
|
||||
</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
|
||||
isLoading={isLoading}
|
||||
sharedSecret={data}
|
||||
decryptedSecret={decryptedSecret}
|
||||
timeLeft={timeLeft}
|
||||
isUrlCopied={isUrlCopied}
|
||||
copyUrlToClipboard={copyUrlToClipboard}
|
||||
/>
|
||||
|
||||
@@ -1,96 +1,44 @@
|
||||
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
SecretInput,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
|
||||
import { EmptyState, IconButton, SecretInput, Td, Tr } from "@app/components/v2";
|
||||
|
||||
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">
|
||||
<SecretInput
|
||||
isVisible
|
||||
value={decryptedSecret}
|
||||
readOnly
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
}: Props) => (
|
||||
<div className="flex items-center rounded border border-solid border-mineshaft-700 bg-mineshaft-800 p-4">
|
||||
{isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
|
||||
{!isLoading && !decryptedSecret && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && decryptedSecret && (
|
||||
<>
|
||||
<div className="min-w-[12rem] max-w-[20rem] flex-1 break-words pr-4">
|
||||
<SecretInput isVisible value={decryptedSecret} readOnly />
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user