From 1a976c78effadeb18c33d0b3b2bb9842fa91248a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 18 Dec 2025 04:27:24 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20separate=20org=20settings?= =?UTF-8?q?=20page=20into=20multiple=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/general/page.tsx | 1895 +++++++++------------ src/contexts/orgContext.ts | 3 +- src/providers/OrgProvider.tsx | 25 +- 3 files changed, 765 insertions(+), 1158 deletions(-) diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 9134ae9e..fa28dd05 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -8,7 +8,13 @@ import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; +import { + useState, + useRef, + useTransition, + useActionState, + type ComponentRef +} from "react"; import { Form, FormControl, @@ -53,6 +59,8 @@ 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"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ @@ -111,82 +119,35 @@ const LOG_RETENTION_OPTIONS = [ ]; export default function GeneralPage() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const router = useRouter(); const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); - const { user } = useUserContext(); + return ( + +
+ + + + + {build !== "oss" && ( + + )} + {build !== "saas" && } +
+
+ ); +} + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +function DeleteForm({ org }: SectionFormProps) { const t = useTranslations(); - const { env } = useEnvContext(); - const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + const api = createApiClient(useEnvContext()); - const [loadingDelete, setLoadingDelete] = useState(false); - const [loadingSave, setLoadingSave] = useState(false); - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = - useState(false); - - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - name: org?.org.name, - subnet: org?.org.subnet || "", // Add default value for subnet - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null, - settingsLogRetentionDaysRequest: - org.org.settingsLogRetentionDaysRequest ?? 15, - settingsLogRetentionDaysAccess: - org.org.settingsLogRetentionDaysAccess ?? 15, - settingsLogRetentionDaysAction: - org.org.settingsLogRetentionDaysAction ?? 15 - }, - mode: "onChange" - }); - - // Track initial security policy values - const initialSecurityValues = { - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null - }; - - // 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 - ); - }; - - async function deleteOrg() { - setLoadingDelete(true); - try { - const res = await api.delete>( - `/org/${org?.org.orgId}` - ); - toast({ - title: t("orgDeleted"), - description: t("orgDeletedMessage") - }); - if (res.status === 200) { - pickNewOrgAndNavigate(); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("orgErrorDelete"), - description: formatAxiosError(err, t("orgErrorDeleteMessage")) - }); - } finally { - setLoadingDelete(false); - } - } + const router = useRouter(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [loadingDelete, startTransition] = useTransition(); + const { user } = useUserContext(); async function pickNewOrgAndNavigate() { try { @@ -213,57 +174,29 @@ export default function GeneralPage() { }); } } - - async function onSubmit(data: GeneralFormValues) { - // Check if security policies have changed - if (hasSecurityPolicyChanged()) { - setIsSecurityPolicyConfirmOpen(true); - return; - } - - await performSave(data); - } - - async function performSave(data: GeneralFormValues) { - setLoadingSave(true); - + async function deleteOrg() { try { - const reqData = { - name: data.name, - settingsLogRetentionDaysRequest: - data.settingsLogRetentionDaysRequest, - settingsLogRetentionDaysAccess: - data.settingsLogRetentionDaysAccess, - settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction - } as any; - if (build !== "oss") { - reqData.requireTwoFactor = data.requireTwoFactor || false; - reqData.maxSessionLengthHours = data.maxSessionLengthHours; - reqData.passwordExpiryDays = data.passwordExpiryDays; - } - - // Update organization - await api.post(`/org/${org?.org.orgId}`, reqData); - + const res = await api.delete>( + `/org/${org.orgId}` + ); toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") + title: t("orgDeleted"), + description: t("orgDeletedMessage") }); - router.refresh(); - } catch (e) { + if (res.status === 200) { + pickNewOrgAndNavigate(); + } + } catch (err) { + console.error(err); toast({ variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) + title: t("orgErrorDelete"), + description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); - } finally { - setLoadingSave(false); } } - return ( - + <> { @@ -276,638 +209,28 @@ export default function GeneralPage() { } buttonText={t("orgDeleteConfirm")} - onConfirm={deleteOrg} - string={org?.org.name || ""} + onConfirm={async () => startTransition(deleteOrg)} + string={org.name || ""} title={t("orgDelete")} /> - -

{t("securityPolicyChangeDescription")}

- - } - buttonText={t("saveSettings")} - onConfirm={() => performSave(form.getValues())} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - -
- - - - - {t("general")} - - - {t("orgGeneralSettingsDescription")} - - - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - -
- - - - {t("logRetention")} - - - {t("logRetentionDescription")} - - - - - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build !== "oss" && ( - <> - - - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - - - {build !== "oss" && ( - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = !isPaidUser; - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "maxSessionLength" - )} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "passwordExpiryDays" - )} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> -
-
-
- )} - -
- -
-
-
- - - {build !== "saas" && ( - - - - {t("dangerSection")} - - - {t("dangerSectionDescription")} - - -
- -
-
- )} -
- ); -} - -function GeneralSectionForm() { - const { org } = useOrgContext(); - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - name: org?.org.name - }, - mode: "onChange" - }); - const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); - const { isPaidUser } = usePaidStatus(); - return ( - <> - {t("general")} + + {t("dangerSection")} + - {t("orgGeneralSettingsDescription")} + {t("dangerSectionDescription")} - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - -
+
@@ -915,19 +238,180 @@ function GeneralSectionForm() { ); } -function LogRetentionSectionForm() { - const { org } = useOrgContext(); +function GeneralSectionForm({ org }: SectionFormProps) { const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + resolver: zodResolver( + GeneralFormSchema.pick({ + name: true, + subnet: true + }) + ), defaultValues: { - name: org?.org.name + name: org.name, + subnet: org.subnet || "" // Add default value for subnet }, mode: "onChange" }); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); - const { isPaidUser } = usePaidStatus(); + const router = useRouter(); + + 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 = { + name: data.name + } 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 ( + + + {t("general")} + + {t("orgGeneralSettingsDescription")} + + + + +
+ + ( + + {t("name")} + + + + + + {t("orgDisplayName")} + + + )} + /> + ( + + {t("subnet")} + + + + + + {t("subnetDescription")} + + + )} + /> + + +
+
+ +
+ +
+
+ ); +} + +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 ( @@ -939,421 +423,566 @@ function LogRetentionSectionForm() { - ( - - - {t("logRetentionRequestLabel")} - - - + field.onChange( + parseInt(value, 10) + ) } - ).map((option) => ( - - {t(option.label)} - - ))} - - - - - - )} - /> - - {build != "oss" && ( - <> - - - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t("logRetentionAccessLabel")} - - - - - - - ); - }} - /> - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t("logRetentionActionLabel")} - - - - - - - ); - }} + ).map((option) => ( + + {t(option.label)} + + ))} + + + + + + )} /> - - )} + + {build !== "oss" && ( + <> + + + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + + +
+ +
); } -function SectionForm() { - const { org } = useOrgContext(); +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + resolver: zodResolver( + GeneralFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), defaultValues: { - name: org?.org.name + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null }, mode: "onChange" }); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); const { isPaidUser } = usePaidStatus(); - return ( - - {build !== "oss" && ( - <> -
- - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = - isSecurityFeatureDisabled(); + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; - return ( - -
+ 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>(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 ( + <> + +

{t("securityPolicyChangeDescription")}

+
+ } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + { + const isDisabled = !isPaidUser; + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("maxSessionLength")} + - { if (!isDisabled) { + const numValue = + value === + "null" + ? null + : parseInt( + value, + 10 + ); form.setValue( - "requireTwoFactor", - val + "maxSessionLengthHours", + numValue ); } }} - /> + disabled={isDisabled} + > + + + + + {SESSION_LENGTH_OPTIONS.map( + (option) => ( + + {t( + option.labelKey + )} + + ) + )} + + -
- - - {t( - "requireTwoFactorDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("passwordExpiryDays")} - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> - - - - )} -
+ + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + + + + +
+ +
+ + ); } diff --git a/src/contexts/orgContext.ts b/src/contexts/orgContext.ts index dbe92d50..e5141bde 100644 --- a/src/contexts/orgContext.ts +++ b/src/contexts/orgContext.ts @@ -1,9 +1,8 @@ import { GetOrgResponse } from "@server/routers/org"; import { createContext } from "react"; -interface OrgContextType { +export interface OrgContextType { org: GetOrgResponse; - updateOrg: (updateOrg: Partial) => void; } const OrgContext = createContext(undefined); diff --git a/src/providers/OrgProvider.tsx b/src/providers/OrgProvider.tsx index adceeff0..122e0127 100644 --- a/src/providers/OrgProvider.tsx +++ b/src/providers/OrgProvider.tsx @@ -10,36 +10,15 @@ interface OrgProviderProps { org: GetOrgResponse | null; } -export function OrgProvider({ children, org: serverOrg }: OrgProviderProps) { - const [org, setOrg] = useState(serverOrg); - +export function OrgProvider({ children, org }: OrgProviderProps) { const t = useTranslations(); if (!org) { throw new Error(t("orgErrorNoProvided")); } - const updateOrg = (updatedOrg: Partial) => { - if (!org) { - throw new Error(t("orgErrorNoUpdate")); - } - - setOrg((prev) => { - if (!prev) { - return prev; - } - - return { - ...prev, - ...updatedOrg - }; - }); - }; - return ( - - {children} - + {children} ); }