mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 15:27:27 +00:00
Merge pull request #3615 from Infisical/ENG-2787
feat(org): Shared Secret limits for org
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
|
||||||
|
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
|
||||||
|
|
||||||
|
if (!hasLifetimeColumn || !hasViewLimitColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
if (!hasLifetimeColumn) {
|
||||||
|
t.integer("maxSharedSecretLifetime").nullable().defaultTo(2592000); // 30 days in seconds
|
||||||
|
}
|
||||||
|
if (!hasViewLimitColumn) {
|
||||||
|
t.integer("maxSharedSecretViewLimit").nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
|
||||||
|
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
|
||||||
|
|
||||||
|
if (hasLifetimeColumn || hasViewLimitColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
if (hasLifetimeColumn) {
|
||||||
|
t.dropColumn("maxSharedSecretLifetime");
|
||||||
|
}
|
||||||
|
if (hasViewLimitColumn) {
|
||||||
|
t.dropColumn("maxSharedSecretViewLimit");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export const OrganizationsSchema = z.object({
|
|||||||
kmsProductEnabled: z.boolean().default(true).nullable().optional(),
|
kmsProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
sshProductEnabled: z.boolean().default(true).nullable().optional(),
|
sshProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional()
|
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
|
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
|
||||||
|
maxSharedSecretViewLimit: z.number().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||||
|
|||||||
@@ -281,7 +281,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
kmsProductEnabled: z.boolean().optional(),
|
kmsProductEnabled: z.boolean().optional(),
|
||||||
sshProductEnabled: z.boolean().optional(),
|
sshProductEnabled: z.boolean().optional(),
|
||||||
scannerProductEnabled: z.boolean().optional(),
|
scannerProductEnabled: z.boolean().optional(),
|
||||||
shareSecretsProductEnabled: z.boolean().optional()
|
shareSecretsProductEnabled: z.boolean().optional(),
|
||||||
|
maxSharedSecretLifetime: z
|
||||||
|
.number()
|
||||||
|
.min(300, "Max Shared Secret lifetime cannot be under 5 minutes")
|
||||||
|
.max(2592000, "Max Shared Secret lifetime cannot exceed 30 days")
|
||||||
|
.optional(),
|
||||||
|
maxSharedSecretViewLimit: z
|
||||||
|
.number()
|
||||||
|
.min(1, "Max Shared Secret view count cannot be lower than 1")
|
||||||
|
.max(1000, "Max Shared Secret view count cannot exceed 1000")
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
|||||||
kmsProductEnabled: true,
|
kmsProductEnabled: true,
|
||||||
sshProductEnabled: true,
|
sshProductEnabled: true,
|
||||||
scannerProductEnabled: true,
|
scannerProductEnabled: true,
|
||||||
shareSecretsProductEnabled: true
|
shareSecretsProductEnabled: true,
|
||||||
|
maxSharedSecretLifetime: true,
|
||||||
|
maxSharedSecretViewLimit: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -361,7 +361,9 @@ export const orgServiceFactory = ({
|
|||||||
kmsProductEnabled,
|
kmsProductEnabled,
|
||||||
sshProductEnabled,
|
sshProductEnabled,
|
||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled
|
shareSecretsProductEnabled,
|
||||||
|
maxSharedSecretLifetime,
|
||||||
|
maxSharedSecretViewLimit
|
||||||
}
|
}
|
||||||
}: TUpdateOrgDTO) => {
|
}: TUpdateOrgDTO) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
@@ -469,7 +471,9 @@ export const orgServiceFactory = ({
|
|||||||
kmsProductEnabled,
|
kmsProductEnabled,
|
||||||
sshProductEnabled,
|
sshProductEnabled,
|
||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled
|
shareSecretsProductEnabled,
|
||||||
|
maxSharedSecretLifetime,
|
||||||
|
maxSharedSecretViewLimit
|
||||||
});
|
});
|
||||||
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
||||||
return org;
|
return org;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
|
|||||||
sshProductEnabled: boolean;
|
sshProductEnabled: boolean;
|
||||||
scannerProductEnabled: boolean;
|
scannerProductEnabled: boolean;
|
||||||
shareSecretsProductEnabled: boolean;
|
shareSecretsProductEnabled: boolean;
|
||||||
|
maxSharedSecretLifetime: number;
|
||||||
|
maxSharedSecretViewLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
} & TOrgPermission;
|
} & TOrgPermission;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ export const secretSharingServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fiveMins = 5 * 60 * 1000;
|
const fiveMins = 5 * 60 * 1000;
|
||||||
if (expiryTime - currentTime < fiveMins) {
|
|
||||||
|
// 1 second buffer
|
||||||
|
if (expiryTime - currentTime + 1000 < fiveMins) {
|
||||||
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
|
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -97,6 +99,20 @@ export const secretSharingServiceFactory = ({
|
|||||||
throw new BadRequestError({ message: "Shared secret value too long" });
|
throw new BadRequestError({ message: "Shared secret value too long" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check lifetime is within org allowance
|
||||||
|
const expiresAtTimestamp = new Date(expiresAt).getTime();
|
||||||
|
const lifetime = expiresAtTimestamp - new Date().getTime();
|
||||||
|
|
||||||
|
// org.maxSharedSecretLifetime is in seconds
|
||||||
|
if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) {
|
||||||
|
throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max view count is within org allowance
|
||||||
|
if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) {
|
||||||
|
throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" });
|
||||||
|
}
|
||||||
|
|
||||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||||
|
|
||||||
let salt: string | undefined;
|
let salt: string | undefined;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
{...props}
|
{...props}
|
||||||
|
disabled={isDisabled}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
|
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
|
||||||
isSelected && "bg-primary",
|
isSelected && "bg-primary",
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export const useUpdateOrg = () => {
|
|||||||
kmsProductEnabled,
|
kmsProductEnabled,
|
||||||
sshProductEnabled,
|
sshProductEnabled,
|
||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled
|
shareSecretsProductEnabled,
|
||||||
|
maxSharedSecretLifetime,
|
||||||
|
maxSharedSecretViewLimit
|
||||||
}) => {
|
}) => {
|
||||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||||
name,
|
name,
|
||||||
@@ -136,7 +138,9 @@ export const useUpdateOrg = () => {
|
|||||||
kmsProductEnabled,
|
kmsProductEnabled,
|
||||||
sshProductEnabled,
|
sshProductEnabled,
|
||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled
|
shareSecretsProductEnabled,
|
||||||
|
maxSharedSecretLifetime,
|
||||||
|
maxSharedSecretViewLimit
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type Organization = {
|
|||||||
sshProductEnabled: boolean;
|
sshProductEnabled: boolean;
|
||||||
scannerProductEnabled: boolean;
|
scannerProductEnabled: boolean;
|
||||||
shareSecretsProductEnabled: boolean;
|
shareSecretsProductEnabled: boolean;
|
||||||
|
maxSharedSecretLifetime: number;
|
||||||
|
maxSharedSecretViewLimit: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateOrgDTO = {
|
export type UpdateOrgDTO = {
|
||||||
@@ -46,6 +48,8 @@ export type UpdateOrgDTO = {
|
|||||||
sshProductEnabled?: boolean;
|
sshProductEnabled?: boolean;
|
||||||
scannerProductEnabled?: boolean;
|
scannerProductEnabled?: boolean;
|
||||||
shareSecretsProductEnabled?: boolean;
|
shareSecretsProductEnabled?: boolean;
|
||||||
|
maxSharedSecretViewLimit?: number | null;
|
||||||
|
maxSharedSecretLifetime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BillingDetails = {
|
export type BillingDetails = {
|
||||||
|
|||||||
+2
@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
allowSecretSharingOutsideOrganization={
|
allowSecretSharingOutsideOrganization={
|
||||||
currentOrg?.allowSecretSharingOutsideOrganization ?? true
|
currentOrg?.allowSecretSharingOutsideOrganization ?? true
|
||||||
}
|
}
|
||||||
|
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
|
||||||
|
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
|
||||||
/>
|
/>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+2
-2
@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||||
<div className="w-full max-w-7xl">
|
<div className="w-full max-w-7xl">
|
||||||
<PageHeader title={t("settings.org.title")} />
|
<PageHeader title="Secret Share Settings" />
|
||||||
<SecretSharingSettingsTabGroup />
|
<SecretSharingSettingsTabGroup />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
|
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
|
||||||
|
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||||
|
import { useUpdateOrg } from "@app/hooks/api";
|
||||||
|
|
||||||
|
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
|
||||||
|
|
||||||
|
// Helper function to convert duration to seconds
|
||||||
|
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
|
||||||
|
switch (unit) {
|
||||||
|
case "m":
|
||||||
|
return value * 60;
|
||||||
|
case "h":
|
||||||
|
return value * 60 * 60;
|
||||||
|
case "d":
|
||||||
|
return value * 60 * 60 * 24;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to convert seconds to form lifetime value and unit
|
||||||
|
const getFormLifetimeFromSeconds = (
|
||||||
|
totalSeconds: number | null | undefined
|
||||||
|
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
|
||||||
|
const DEFAULT_LIFETIME_VALUE = 30;
|
||||||
|
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
|
||||||
|
|
||||||
|
if (totalSeconds == null || totalSeconds <= 0) {
|
||||||
|
return {
|
||||||
|
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
|
||||||
|
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsInDay = 24 * 60 * 60;
|
||||||
|
const secondsInHour = 60 * 60;
|
||||||
|
const secondsInMinute = 60;
|
||||||
|
|
||||||
|
if (totalSeconds % secondsInDay === 0) {
|
||||||
|
const value = totalSeconds / secondsInDay;
|
||||||
|
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSeconds % secondsInHour === 0) {
|
||||||
|
const value = totalSeconds / secondsInHour;
|
||||||
|
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSeconds % secondsInMinute === 0) {
|
||||||
|
const value = totalSeconds / secondsInMinute;
|
||||||
|
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
|
||||||
|
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
|
||||||
|
maxLifetimeUnit: z.enum(["m", "h", "d"], {
|
||||||
|
invalid_type_error: "Please select a valid time unit"
|
||||||
|
}),
|
||||||
|
maxViewLimit: z.string()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const { maxLifetimeValue, maxLifetimeUnit } = data;
|
||||||
|
|
||||||
|
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
|
||||||
|
|
||||||
|
// Check max limit
|
||||||
|
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
|
||||||
|
let message = "Duration exceeds maximum allowed limit";
|
||||||
|
|
||||||
|
if (maxLifetimeUnit === "m") {
|
||||||
|
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
|
||||||
|
} else if (maxLifetimeUnit === "h") {
|
||||||
|
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
|
||||||
|
} else if (maxLifetimeUnit === "d") {
|
||||||
|
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message,
|
||||||
|
path: ["maxLifetimeValue"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check min limit
|
||||||
|
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
|
||||||
|
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message,
|
||||||
|
path: ["maxLifetimeValue"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const viewLimitOptions = [
|
||||||
|
{ label: "1", value: 1 },
|
||||||
|
{ label: "Unlimited", value: -1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OrgSecretShareLimitSection = () => {
|
||||||
|
const { mutateAsync } = useUpdateOrg();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
|
||||||
|
const getDefaultFormValues = () => {
|
||||||
|
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
|
||||||
|
return {
|
||||||
|
maxLifetimeValue: initialLifetime.maxLifetimeValue,
|
||||||
|
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
|
||||||
|
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "-1"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting, isDirty },
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: getDefaultFormValues()
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentOrg) {
|
||||||
|
reset(getDefaultFormValues());
|
||||||
|
}
|
||||||
|
}, [currentOrg, reset]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (formData: TForm) => {
|
||||||
|
try {
|
||||||
|
const maxSharedSecretLifetimeSeconds = durationToSeconds(
|
||||||
|
formData.maxLifetimeValue,
|
||||||
|
formData.maxLifetimeUnit
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
orgId: currentOrg.id,
|
||||||
|
maxSharedSecretViewLimit:
|
||||||
|
formData.maxViewLimit === "-1" ? null : Number(formData.maxViewLimit),
|
||||||
|
maxSharedSecretLifetime: maxSharedSecretLifetimeSeconds
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully updated secret share limits",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
reset(formData);
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update secret share limits",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Units for the dropdown with readable labels
|
||||||
|
const timeUnits = [
|
||||||
|
{ value: "m", label: "Minutes" },
|
||||||
|
{ value: "h", label: "Hours" },
|
||||||
|
{ value: "d", label: "Days" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<p className="text-xl font-semibold">Secret Share Limits</p>
|
||||||
|
</div>
|
||||||
|
<p className="mb-4 mt-2 text-sm text-gray-400">
|
||||||
|
These settings establish the maximum limits for all Shared Secret parameters within this
|
||||||
|
organization. Shared secrets cannot be created with values exceeding these limits.
|
||||||
|
</p>
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
|
||||||
|
<div className="flex max-w-sm gap-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxLifetimeValue"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
tooltipText="The max amount of time that can be set before the secret share link expires."
|
||||||
|
label="Max Lifetime"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
field.onChange(val === "" ? "" : parseInt(val, 10));
|
||||||
|
}}
|
||||||
|
disabled={!isAllowed}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxLifetimeUnit"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Time unit"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
className="pr-2"
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Select time unit"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
{timeUnits.map(({ value, label }) => (
|
||||||
|
<SelectItem
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
|
||||||
|
>
|
||||||
|
<div className="ml-3 font-medium">{label}</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-sm">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxViewLimit"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Max Views"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||||
|
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
disabled={!isDirty || !isAllowed}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
||||||
+2
@@ -1,9 +1,11 @@
|
|||||||
|
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
|
||||||
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
|
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
|
||||||
|
|
||||||
export const SecretSharingSettingsGeneralTab = () => {
|
export const SecretSharingSettingsGeneralTab = () => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<SecretSharingAllowShareToAnyone />
|
<SecretSharingAllowShareToAnyone />
|
||||||
|
<OrgSecretShareLimitSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+52
-54
@@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => {
|
|||||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
|
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
|
||||||
<div className="flex max-w-md gap-4">
|
<div className="flex max-w-sm gap-4">
|
||||||
<div className="flex-1">
|
<Controller
|
||||||
<Controller
|
control={control}
|
||||||
control={control}
|
name="expirationValue"
|
||||||
name="expirationValue"
|
render={({ field, fieldState: { error } }) => (
|
||||||
render={({ field, fieldState: { error } }) => (
|
<FormControl
|
||||||
<FormControl
|
isError={Boolean(error)}
|
||||||
isError={Boolean(error)}
|
errorText={error?.message}
|
||||||
errorText={error?.message}
|
label="Expiration value"
|
||||||
label="Expiration value"
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||||
|
disabled={!isAllowed}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expirationUnit"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Time unit"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
className="pr-2"
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Select time unit"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
<Input
|
{timeUnits.map(({ value, label }) => (
|
||||||
{...field}
|
<SelectItem
|
||||||
type="number"
|
key={value}
|
||||||
min={1}
|
value={value}
|
||||||
step={1}
|
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
|
||||||
value={field.value}
|
>
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
<div className="ml-3 font-medium">{label}</div>
|
||||||
disabled={!isAllowed}
|
</SelectItem>
|
||||||
/>
|
))}
|
||||||
</FormControl>
|
</Select>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expirationUnit"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
label="Time unit"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={field.value}
|
|
||||||
className="pr-2"
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
placeholder="Select time unit"
|
|
||||||
isDisabled={!isAllowed}
|
|
||||||
>
|
|
||||||
{timeUnits.map(({ value, label }) => (
|
|
||||||
<SelectItem
|
|
||||||
key={value}
|
|
||||||
value={value}
|
|
||||||
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
|
|
||||||
>
|
|
||||||
<div className="ml-3 font-medium">{label}</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
|
|||||||
@@ -71,12 +71,16 @@ type Props = {
|
|||||||
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
|
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
|
||||||
value?: string;
|
value?: string;
|
||||||
allowSecretSharingOutsideOrganization?: boolean;
|
allowSecretSharingOutsideOrganization?: boolean;
|
||||||
|
maxSharedSecretLifetime?: number;
|
||||||
|
maxSharedSecretViewLimit?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ShareSecretForm = ({
|
export const ShareSecretForm = ({
|
||||||
isPublic,
|
isPublic,
|
||||||
value,
|
value,
|
||||||
allowSecretSharingOutsideOrganization = true
|
allowSecretSharingOutsideOrganization = true,
|
||||||
|
maxSharedSecretLifetime,
|
||||||
|
maxSharedSecretViewLimit
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [secretLink, setSecretLink] = useState<string | null>(null);
|
const [secretLink, setSecretLink] = useState<string | null>(null);
|
||||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||||
@@ -87,6 +91,15 @@ export const ShareSecretForm = ({
|
|||||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||||
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
||||||
|
|
||||||
|
// Note: maxSharedSecretLifetime is in seconds
|
||||||
|
const filteredExpiresInOptions = maxSharedSecretLifetime
|
||||||
|
? expiresInOptions.filter((v) => v.value / 1000 <= maxSharedSecretLifetime)
|
||||||
|
: expiresInOptions;
|
||||||
|
|
||||||
|
const filteredViewLimitOptions = maxSharedSecretViewLimit
|
||||||
|
? viewLimitOptions.filter((v) => v.value > 0 && v.value <= maxSharedSecretViewLimit)
|
||||||
|
: viewLimitOptions;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
reset,
|
reset,
|
||||||
@@ -96,8 +109,9 @@ export const ShareSecretForm = ({
|
|||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
secret: value || "",
|
secret: value || "",
|
||||||
viewLimit: "-1",
|
viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(),
|
||||||
expiresIn: "3600000"
|
expiresIn:
|
||||||
|
filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,6 +291,15 @@ export const ShareSecretForm = ({
|
|||||||
label="Expires In"
|
label="Expires In"
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
|
helperText={
|
||||||
|
expiresInOptions.length !== filteredExpiresInOptions.length ? (
|
||||||
|
<span className="text-yellow-500">
|
||||||
|
Limited to{" "}
|
||||||
|
{filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
|
||||||
|
organization
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
@@ -285,7 +308,11 @@ export const ShareSecretForm = ({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
<SelectItem
|
||||||
|
value={String(expiresInValue || "")}
|
||||||
|
key={label}
|
||||||
|
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -301,6 +328,15 @@ export const ShareSecretForm = ({
|
|||||||
label="Max Views"
|
label="Max Views"
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
|
helperText={
|
||||||
|
viewLimitOptions.length !== filteredViewLimitOptions.length ? (
|
||||||
|
<span className="text-yellow-500">
|
||||||
|
Limited to{" "}
|
||||||
|
{filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
|
||||||
|
organization
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
@@ -309,7 +345,11 @@ export const ShareSecretForm = ({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||||
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
<SelectItem
|
||||||
|
value={String(viewLimitValue || "")}
|
||||||
|
key={label}
|
||||||
|
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user