From ebd3b5c9d1ea1c12b425a52dab4e740126ec7e33 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 15:24:20 +0800 Subject: [PATCH] UI polish: Add better time inputs and tooltips --- frontend/src/helpers/datetime.ts | 86 +++++ .../IdentityUniversalAuthForm.tsx | 335 +++++++++++++----- .../ViewIdentityUniversalAuthContent.tsx | 8 +- .../OrgSecretShareLimitSection.tsx | 82 +---- 4 files changed, 353 insertions(+), 158 deletions(-) diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index 6e0fb8e48..bb258878d 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -22,3 +22,89 @@ export const formatDateTime = ({ } return format(date, dateFormat); }; + +// Helper function to convert duration to seconds +export const durationToSeconds = ( + value: number, + unit: "s" | "m" | "h" | "d" | "w" | "y" +): number => { + switch (unit) { + case "s": + return value; + case "m": + return value * 60; + case "h": + return value * 60 * 60; + case "d": + return value * 60 * 60 * 24; + case "w": + return value * 60 * 60 * 24 * 7; + case "y": + return value * 60 * 60 * 24 * 365; + default: + return 0; + } +}; + +// Helper function to convert seconds to value and unit +export const getObjectFromSeconds = ( + totalSeconds: number, + activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y"> +): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => { + const SECONDS_IN_MINUTE = 60; + const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; + const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; + + const activeUnitsSet = activeUnits ? new Set(activeUnits) : null; + + const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => { + return activeUnitsSet ? activeUnitsSet.has(unit) : true; + }; + + if ( + isUnitActive("y") && + totalSeconds >= SECONDS_IN_YEAR && + totalSeconds % SECONDS_IN_YEAR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" }; + } + + if ( + isUnitActive("w") && + totalSeconds >= SECONDS_IN_WEEK && + totalSeconds % SECONDS_IN_WEEK === 0 + ) { + return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" }; + } + + if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) { + return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" }; + } + + if ( + isUnitActive("h") && + totalSeconds >= SECONDS_IN_HOUR && + totalSeconds % SECONDS_IN_HOUR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" }; + } + + if ( + isUnitActive("m") && + totalSeconds >= SECONDS_IN_MINUTE && + totalSeconds % SECONDS_IN_MINUTE === 0 + ) { + return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; + } + + if (isUnitActive("s") && totalSeconds >= 1) { + return { value: totalSeconds, unit: "s" }; + } + + return { + value: 0, + unit: "s" + }; +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index 05bf47c73..c8d424e47 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -11,6 +11,8 @@ import { FormControl, IconButton, Input, + Select, + SelectItem, Switch, Tab, TabList, @@ -27,6 +29,7 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; const schema = z .object({ @@ -69,20 +72,73 @@ const schema = z (value) => Number(value) <= 30 && Number(value) >= 1, "Lockout threshold must be between 1 and 30" ), - lockoutDuration: z - .string() - .refine( - (value) => Number(value) <= 86400 && Number(value) >= 30, - "Lockout duration must be between 30 seconds and 1 day" - ), - lockoutCounterReset: z - .string() - .refine( - (value) => Number(value) <= 3600 && Number(value) >= 5, - "Lockout counter reset must be between 5 seconds and 1 hour" - ) + lockoutDurationValue: z.string(), + lockoutDurationUnit: z.enum(["s", "m", "h", "d"], { + invalid_type_error: "Please select a valid time unit" + }), + lockoutCounterResetValue: z.string(), + lockoutCounterResetUnit: z.enum(["s", "m", "h"], { + invalid_type_error: "Please select a valid time unit" + }) }) - .required(); + .required() + .superRefine((data, ctx) => { + const { + lockoutDurationValue, + lockoutCounterResetValue, + lockoutDurationUnit, + lockoutCounterResetUnit, + lockoutEnabled + } = data; + + if (!lockoutEnabled) return; + + let isAnyParseError = false; + + const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); + if (isNaN(parsedLockoutDuration)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be a number", + path: ["lockoutDurationValue"] + }); + isAnyParseError = true; + } + + const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10); + if (isNaN(parsedLockoutCounterReset)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be a number", + path: ["lockoutCounterResetValue"] + }); + isAnyParseError = true; + } + + if (isAnyParseError) return; + + const lockoutDurationInSeconds = durationToSeconds(parsedLockoutDuration, lockoutDurationUnit); + const lockoutCounterResetInSeconds = durationToSeconds( + parsedLockoutCounterReset, + lockoutCounterResetUnit + ); + + if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be between 30 seconds and 1 day", + path: ["lockoutDurationValue"] + }); + } + + if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be between 5 seconds and 1 hour", + path: ["lockoutCounterResetValue"] + }); + } + }); export type FormData = z.infer; @@ -130,8 +186,10 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: "0", lockoutEnabled: true, lockoutThreshold: "3", - lockoutDuration: "300", - lockoutCounterReset: "30" + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" } }); @@ -139,8 +197,10 @@ export const IdentityUniversalAuthForm = ({ const lockoutEnabled = watch("lockoutEnabled"); const lockoutThreshold = watch("lockoutThreshold"); - const lockoutDuration = watch("lockoutDuration"); - const lockoutCounterReset = watch("lockoutCounterReset"); + const lockoutDurationValue = watch("lockoutDurationValue"); + const lockoutDurationUnit = watch("lockoutDurationUnit"); + const lockoutCounterResetValue = watch("lockoutCounterResetValue"); + const lockoutCounterResetUnit = watch("lockoutCounterResetUnit"); const { fields: clientSecretTrustedIpsFields, @@ -155,6 +215,9 @@ export const IdentityUniversalAuthForm = ({ useEffect(() => { if (data) { + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset); + reset({ accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), @@ -176,8 +239,10 @@ export const IdentityUniversalAuthForm = ({ ), lockoutEnabled: data.lockoutEnabled, lockoutThreshold: String(data.lockoutThreshold), - lockoutDuration: String(data.lockoutDuration), - lockoutCounterReset: String(data.lockoutCounterReset) + lockoutDurationValue: String(lockoutDurationObj.value), + lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d", + lockoutCounterResetValue: String(lockoutCounterResetObj.value), + lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h" }); } else { reset({ @@ -189,8 +254,10 @@ export const IdentityUniversalAuthForm = ({ accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], lockoutEnabled: true, lockoutThreshold: "3", - lockoutDuration: "300", - lockoutCounterReset: "30" + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" }); } }, [data]); @@ -204,12 +271,20 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationValue, + lockoutDurationUnit, + lockoutCounterResetValue, + lockoutCounterResetUnit }: FormData) => { try { if (!identityId) return; + const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit); + const lockoutCounterReset = durationToSeconds( + Number(lockoutCounterResetValue), + lockoutCounterResetUnit + ); + if (data) { // update universal auth configuration await updateMutateAsync({ @@ -223,8 +298,8 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: Number(accessTokenPeriod), lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration: Number(lockoutDuration), - lockoutCounterReset: Number(lockoutCounterReset) + lockoutDuration, + lockoutCounterReset }); } else { // create new universal auth configuration @@ -272,8 +347,10 @@ export const IdentityUniversalAuthForm = ({ : [ "lockoutEnabled", "lockoutThreshold", - "lockoutDuration", - "lockoutCounterReset" + "lockoutDurationValue", + "lockoutDurationUnit", + "lockoutCounterResetValue", + "lockoutCounterResetUnit" ].includes(Object.keys(fields)[0]) ? IdentityFormTab.Lockout : IdentityFormTab.Configuration @@ -362,7 +439,7 @@ export const IdentityUniversalAuthForm = ({ render={({ field: { value, onChange }, fieldState: { error } }) => { return ( @@ -380,57 +457,157 @@ export const IdentityUniversalAuthForm = ({ ); }} /> - { - return ( - - - - ); - }} - /> - { - return ( - - - - ); - }} - /> - { - return ( - - - - ); - }} - /> +
+ { + return ( + + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index c2a1df6b9..aac1ee52c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -120,13 +120,7 @@ export const ViewIdentityUniversalAuthContent = ({ isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending} size="xs" onClick={clearLockouts} - leftIcon={ - isClearLockoutsPending ? ( - - ) : ( - - ) - } + isLoading={isClearLockoutsPending} colorSchema="secondary" > Clear All Lockouts diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx index 648ad031c..71d133d2e 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -8,63 +8,11 @@ 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"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; 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"), @@ -79,32 +27,18 @@ const formSchema = z 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, + message: "Duration exceeds a maximum of 30 days", 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, + message: "Duration must be at least 5 minutes", path: ["maxLifetimeValue"] }); } @@ -122,10 +56,14 @@ export const OrgSecretShareLimitSection = () => { const { currentOrg } = useOrganization(); const getDefaultFormValues = () => { - const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime); + const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [ + "m", + "h", + "d" + ]); return { - maxLifetimeValue: initialLifetime.maxLifetimeValue, - maxLifetimeUnit: initialLifetime.maxLifetimeUnit, + maxLifetimeValue: initialLifetime.value, + maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d", maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1", shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit) };