split org security settings to new tab

This commit is contained in:
miloschwartz
2026-01-18 12:02:51 -08:00
parent d696556097
commit a3fa12f0e4
3 changed files with 757 additions and 710 deletions

View File

@@ -51,6 +51,10 @@ export default async function GeneralSettingsPage({
title: t("general"), title: t("general"),
href: `/{orgId}/settings/general`, href: `/{orgId}/settings/general`,
exact: true exact: true
},
{
title: t("security"),
href: `/{orgId}/settings/general/security`
} }
]; ];
if (build !== "oss") { if (build !== "oss") {

View File

@@ -1,19 +1,12 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import {
useState, useState,
useRef,
useTransition, useTransition,
useActionState, useActionState
type ComponentRef
} from "react"; } from "react";
import { import {
Form, Form,
@@ -25,13 +18,6 @@ import {
FormMessage FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -55,79 +41,19 @@ import {
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
import type { OrgContextType } from "@app/contexts/orgContext"; import type { OrgContextType } from "@app/contexts/orgContext";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
{ value: null, labelKey: "unenforced" },
{ value: 1, labelKey: "1Hour" },
{ value: 3, labelKey: "3Hours" },
{ value: 6, labelKey: "6Hours" },
{ value: 12, labelKey: "12Hours" },
{ value: 24, labelKey: "1DaySession" },
{ value: 72, labelKey: "3Days" },
{ value: 168, labelKey: "7Days" },
{ value: 336, labelKey: "14Days" },
{ value: 720, labelKey: "30DaysSession" },
{ value: 2160, labelKey: "90DaysSession" },
{ value: 4320, labelKey: "180DaysSession" }
];
// Password expiry options in days - will be translated in component
const PASSWORD_EXPIRY_OPTIONS = [
{ value: null, labelKey: "neverExpire" },
{ value: 1, labelKey: "1Day" },
{ value: 30, labelKey: "30Days" },
{ value: 60, labelKey: "60Days" },
{ value: 90, labelKey: "90Days" },
{ value: 180, labelKey: "180Days" },
{ value: 365, labelKey: "1Year" }
];
// Schema for general organization settings // Schema for general organization settings
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string(), name: z.string(),
subnet: z.string().optional(), subnet: z.string().optional()
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
}); });
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const LOG_RETENTION_OPTIONS = [
{ label: "logRetentionDisabled", value: 0 },
{ label: "logRetention3Days", value: 3 },
{ label: "logRetention7Days", value: 7 },
{ label: "logRetention14Days", value: 14 },
{ label: "logRetention30Days", value: 30 },
{ label: "logRetention90Days", value: 90 },
...(build != "saas"
? [
{ label: "logRetentionForever", value: -1 },
{ label: "logRetentionEndOfFollowingYear", value: 9001 }
]
: [])
];
export default function GeneralPage() { export default function GeneralPage() {
const { org } = useOrgContext(); const { org } = useOrgContext();
return ( return (
<SettingsContainer> <SettingsContainer>
<GeneralSectionForm org={org.org} /> <GeneralSectionForm org={org.org} />
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
{build !== "saas" && <DeleteForm org={org.org} />} {build !== "saas" && <DeleteForm org={org.org} />}
</SettingsContainer> </SettingsContainer>
); );
@@ -340,637 +266,3 @@ function GeneralSectionForm({ org }: SectionFormProps) {
); );
} }
function LogRetentionSectionForm({ org }: SectionFormProps) {
const form = useForm({
resolver: zodResolver(
GeneralFormSchema.pick({
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true
})
),
defaultValues: {
settingsLogRetentionDaysRequest:
org.settingsLogRetentionDaysRequest ?? 15,
settingsLogRetentionDaysAccess:
org.settingsLogRetentionDaysAccess ?? 15,
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15
},
mode: "onChange"
});
const router = useRouter();
const t = useTranslations();
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
settingsLogRetentionDaysRequest:
data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<FormField
control={form.control}
name="settingsLogRetentionDaysRequest"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logRetentionRequestLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) =>
field.onChange(
parseInt(value, 10)
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
hasSaasSubscription &&
option.value >
30
) {
return false;
}
return true;
}
).map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{build !== "oss" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAccessLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionActionLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(
GeneralFormSchema.pick({
requireTwoFactor: true,
maxSessionLengthHours: true,
passwordExpiryDays: true
})
),
defaultValues: {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
},
mode: "onChange"
});
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
// Track initial security policy values
const initialSecurityValues = {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
};
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
// Check if security policies have changed
const hasSecurityPolicyChanged = () => {
const currentValues = form.getValues();
return (
currentValues.requireTwoFactor !==
initialSecurityValues.requireTwoFactor ||
currentValues.maxSessionLengthHours !==
initialSecurityValues.maxSessionLengthHours ||
currentValues.passwordExpiryDays !==
initialSecurityValues.passwordExpiryDays
);
};
const [, formAction, loadingSave] = useActionState(onSubmit, null);
const api = createApiClient(useEnvContext());
const formRef = useRef<ComponentRef<"form">>(null);
async function onSubmit() {
// Check if security policies have changed
if (hasSecurityPolicyChanged()) {
setIsSecurityPolicyConfirmOpen(true);
return;
}
await performSave();
}
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
requireTwoFactor: data.requireTwoFactor || false,
maxSessionLengthHours: data.maxSessionLengthHours,
passwordExpiryDays: data.passwordExpiryDays
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<div className="flex items-center gap-2">
<FormControl>
<SwitchInput
id="require-two-factor"
defaultChecked={
field.value ||
false
}
label={t(
"requireTwoFactorForAllUsers"
)}
disabled={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -0,0 +1,751 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import {
useState,
useRef,
useActionState,
type ComponentRef
} from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { OrgContextType } from "@app/contexts/orgContext";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
{ value: null, labelKey: "unenforced" },
{ value: 1, labelKey: "1Hour" },
{ value: 3, labelKey: "3Hours" },
{ value: 6, labelKey: "6Hours" },
{ value: 12, labelKey: "12Hours" },
{ value: 24, labelKey: "1DaySession" },
{ value: 72, labelKey: "3Days" },
{ value: 168, labelKey: "7Days" },
{ value: 336, labelKey: "14Days" },
{ value: 720, labelKey: "30DaysSession" },
{ value: 2160, labelKey: "90DaysSession" },
{ value: 4320, labelKey: "180DaysSession" }
];
// Password expiry options in days - will be translated in component
const PASSWORD_EXPIRY_OPTIONS = [
{ value: null, labelKey: "neverExpire" },
{ value: 1, labelKey: "1Day" },
{ value: 30, labelKey: "30Days" },
{ value: 60, labelKey: "60Days" },
{ value: 90, labelKey: "90Days" },
{ value: 180, labelKey: "180Days" },
{ value: 365, labelKey: "1Year" }
];
// Schema for security organization settings
const SecurityFormSchema = z.object({
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
});
const LOG_RETENTION_OPTIONS = [
{ label: "logRetentionDisabled", value: 0 },
{ label: "logRetention3Days", value: 3 },
{ label: "logRetention7Days", value: 7 },
{ label: "logRetention14Days", value: 14 },
{ label: "logRetention30Days", value: 30 },
{ label: "logRetention90Days", value: 90 },
...(build != "saas"
? [
{ label: "logRetentionForever", value: -1 },
{ label: "logRetentionEndOfFollowingYear", value: 9001 }
]
: [])
];
type SectionFormProps = {
org: OrgContextType["org"]["org"];
};
export default function SecurityPage() {
const { org } = useOrgContext();
return (
<SettingsContainer>
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
</SettingsContainer>
);
}
function LogRetentionSectionForm({ org }: SectionFormProps) {
const form = useForm({
resolver: zodResolver(
SecurityFormSchema.pick({
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true
})
),
defaultValues: {
settingsLogRetentionDaysRequest:
org.settingsLogRetentionDaysRequest ?? 15,
settingsLogRetentionDaysAccess:
org.settingsLogRetentionDaysAccess ?? 15,
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15
},
mode: "onChange"
});
const router = useRouter();
const t = useTranslations();
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
settingsLogRetentionDaysRequest:
data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<FormField
control={form.control}
name="settingsLogRetentionDaysRequest"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logRetentionRequestLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) =>
field.onChange(
parseInt(value, 10)
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
hasSaasSubscription &&
option.value >
30
) {
return false;
}
return true;
}
).map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{build !== "oss" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAccessLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionActionLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(
SecurityFormSchema.pick({
requireTwoFactor: true,
maxSessionLengthHours: true,
passwordExpiryDays: true
})
),
defaultValues: {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
},
mode: "onChange"
});
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
// Track initial security policy values
const initialSecurityValues = {
requireTwoFactor: org.requireTwoFactor || false,
maxSessionLengthHours: org.maxSessionLengthHours || null,
passwordExpiryDays: org.passwordExpiryDays || null
};
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
// Check if security policies have changed
const hasSecurityPolicyChanged = () => {
const currentValues = form.getValues();
return (
currentValues.requireTwoFactor !==
initialSecurityValues.requireTwoFactor ||
currentValues.maxSessionLengthHours !==
initialSecurityValues.maxSessionLengthHours ||
currentValues.passwordExpiryDays !==
initialSecurityValues.passwordExpiryDays
);
};
const [, formAction, loadingSave] = useActionState(onSubmit, null);
const api = createApiClient(useEnvContext());
const formRef = useRef<ComponentRef<"form">>(null);
async function onSubmit() {
// Check if security policies have changed
if (hasSecurityPolicyChanged()) {
setIsSecurityPolicyConfirmOpen(true);
return;
}
await performSave();
}
async function performSave() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
const reqData = {
requireTwoFactor: data.requireTwoFactor || false,
maxSessionLengthHours: data.maxSessionLengthHours,
passwordExpiryDays: data.passwordExpiryDays
} as any;
// Update organization
await api.post(`/org/${org.orgId}`, reqData);
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
}
}
return (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<div className="flex items-center gap-2">
<FormControl>
<SwitchInput
id="require-two-factor"
defaultChecked={
field.value ||
false
}
label={t(
"requireTwoFactorForAllUsers"
)}
disabled={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}