mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
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,9 @@ 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().optional(),
|
||||||
|
maxSharedSecretViewLimit: z.number().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;
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,19 @@ 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();
|
||||||
|
|
||||||
|
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();
|
||||||
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { withPermission } from "@app/hoc";
|
|||||||
|
|
||||||
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
|
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
|
||||||
import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection";
|
import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection";
|
||||||
|
import { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
||||||
|
|
||||||
export const OrgSecurityTab = withPermission(
|
export const OrgSecurityTab = withPermission(
|
||||||
() => {
|
() => {
|
||||||
@@ -28,6 +29,7 @@ export const OrgSecurityTab = withPermission(
|
|||||||
</NoticeBannerV2>
|
</NoticeBannerV2>
|
||||||
<OrgGenericAuthSection />
|
<OrgGenericAuthSection />
|
||||||
<OrgUserAccessTokenLimitSection />
|
<OrgUserAccessTokenLimitSection />
|
||||||
|
<OrgSecretShareLimitSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -42,12 +42,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("");
|
const [secretLink, setSecretLink] = useState("");
|
||||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||||
@@ -58,6 +62,14 @@ export const ShareSecretForm = ({
|
|||||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||||
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -183,7 +195,9 @@ export const ShareSecretForm = ({
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="expiresIn"
|
name="expiresIn"
|
||||||
defaultValue="3600000"
|
defaultValue={filteredExpiresInOptions[
|
||||||
|
Math.min(filteredExpiresInOptions.length - 1, 2)
|
||||||
|
].value.toString()}
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
|
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
|
||||||
<Select
|
<Select
|
||||||
@@ -192,7 +206,7 @@ export const ShareSecretForm = ({
|
|||||||
onValueChange={(e) => onChange(e)}
|
onValueChange={(e) => onChange(e)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
{filteredExpiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||||
{label}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -204,7 +218,9 @@ export const ShareSecretForm = ({
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="viewLimit"
|
name="viewLimit"
|
||||||
defaultValue="-1"
|
defaultValue={filteredViewLimitOptions[
|
||||||
|
filteredViewLimitOptions.length - 1
|
||||||
|
].value.toString()}
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
|
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
|
||||||
<Select
|
<Select
|
||||||
@@ -213,7 +229,7 @@ export const ShareSecretForm = ({
|
|||||||
onValueChange={(e) => onChange(e)}
|
onValueChange={(e) => onChange(e)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
{filteredViewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||||
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||||
{label}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
Reference in New Issue
Block a user