diff --git a/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx b/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx index 7a6f767d9b..37b5dd497a 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuFormFooter.tsx @@ -18,6 +18,7 @@ interface GluuFormFooterBaseProps { backButtonLabel?: string onBack?: () => void disableBack?: boolean + backIconClass?: string showCancel?: boolean cancelButtonLabel?: string onCancel?: () => void @@ -54,6 +55,7 @@ const GluuFormFooter = ({ backButtonLabel, onBack, disableBack = false, + backIconClass = 'fa fa-arrow-circle-left', showCancel, cancelButtonLabel, onCancel, @@ -157,7 +159,7 @@ const GluuFormFooter = ({ disabled={disableBack} aria-label={backLabel} > - + )} diff --git a/admin-ui/plugins/auth-server/components/Configuration/Defaults/LoggingPage.js b/admin-ui/plugins/auth-server/components/Configuration/Defaults/LoggingPage.js index 1ee781df21..fda83db13e 100644 --- a/admin-ui/plugins/auth-server/components/Configuration/Defaults/LoggingPage.js +++ b/admin-ui/plugins/auth-server/components/Configuration/Defaults/LoggingPage.js @@ -4,8 +4,10 @@ import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' -import GluuCommitFooter from 'Routes/Apps/Gluu/GluuCommitFooter' +import GluuFormFooter from 'Routes/Apps/Gluu/GluuFormFooter' import { JSON_CONFIG } from 'Utils/ApiResources' +import { loggingValidationSchema } from './validations' +import { LOG_LEVELS, LOG_LAYOUTS, getLoggingInitialValues } from './utils' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { useDispatch, useSelector } from 'react-redux' import { Formik } from 'formik' @@ -25,13 +27,12 @@ function LoggingPage() { const { hasCedarPermission, authorize } = useCedarling() const logging = useSelector((state) => state.loggingReducer.logging) const loading = useSelector((state) => state.loggingReducer.loading) - const { permissions: cedarPermissions } = useSelector((state) => state.cedarPermissions) const dispatch = useDispatch() const [showCommitDialog, setShowCommitDialog] = useState(false) const [pendingValues, setPendingValues] = useState(null) - const [localLogging, setLocalLogging] = useState(null) + const toggleCommitDialog = useCallback(() => setShowCommitDialog((prev) => !prev), []) useEffect(() => { const initPermissions = async () => { @@ -44,38 +45,21 @@ function LoggingPage() { dispatch(getLoggingConfig()) }, [dispatch, authorize]) - useEffect(() => { - if (logging) { - setLocalLogging(logging) - } - }, [logging]) - - useEffect(() => {}, [cedarPermissions]) - - const initialValues = useMemo( - () => ({ - loggingLevel: localLogging?.loggingLevel, - loggingLayout: localLogging?.loggingLayout, - httpLoggingEnabled: localLogging?.httpLoggingEnabled, - disableJdkLogger: localLogging?.disableJdkLogger, - enabledOAuthAuditLogging: localLogging?.enabledOAuthAuditLogging, - }), - [localLogging], - ) + const initialValues = useMemo(() => getLoggingInitialValues(logging), [logging]) - const levels = useMemo(() => ['TRACE', 'DEBUG', 'INFO', 'ERROR', 'WARN'], []) - const logLayouts = useMemo(() => ['text', 'json'], []) + const levels = useMemo(() => LOG_LEVELS, []) + const logLayouts = useMemo(() => LOG_LAYOUTS, []) SetTitle('Logging') const handleSubmit = useCallback( (values) => { - const mergedValues = getMergedValues(localLogging, values) - const changedFields = getChangedFields(localLogging, mergedValues) + const mergedValues = getMergedValues(logging, values) + const changedFields = getChangedFields(logging, mergedValues) setPendingValues({ mergedValues, changedFields }) setShowCommitDialog(true) }, - [localLogging], + [logging], ) const handleAccept = useCallback( @@ -104,7 +88,13 @@ function LoggingPage() { - + {(formik) => (
@@ -113,6 +103,7 @@ function LoggingPage() { size={4} doc_category={JSON_CONFIG} doc_entry="loggingLevel" + required /> formik.setFieldValue('loggingLevel', e.target.value)} + onBlur={() => formik.setFieldTouched('loggingLevel', true)} + required + aria-required="true" > {levels.map((item, key) => ( @@ -130,6 +124,9 @@ function LoggingPage() { ))} + {formik.touched.loggingLevel && formik.errors.loggingLevel && ( +
{formik.errors.loggingLevel}
+ )}
@@ -139,6 +136,7 @@ function LoggingPage() { size={4} doc_category={JSON_CONFIG} doc_entry="loggingLayout" + required /> formik.setFieldValue('loggingLayout', e.target.value)} + onBlur={() => formik.setFieldTouched('loggingLayout', true)} + required + aria-required="true" > {logLayouts.map((item, key) => ( @@ -156,6 +157,9 @@ function LoggingPage() { ))} + {formik.touched.loggingLayout && formik.errors.loggingLayout && ( +
{formik.errors.loggingLayout}
+ )} @@ -192,12 +196,17 @@ function LoggingPage() { {hasCedarPermission(LOGGING_WRITE) && ( - formik.resetForm()} - hideButtons={{ save: true, back: true }} - type="submit" + formik.resetForm()} + disableBack={false} + disableCancel={!formik.dirty} + disableApply={!formik.isValid || !formik.dirty} + applyButtonType="button" + isLoading={loading} /> @@ -207,7 +216,7 @@ function LoggingPage() { setShowCommitDialog(false)} + handler={toggleCommitDialog} modal={showCommitDialog} onAccept={handleAccept} isLicenseLabel={false} diff --git a/admin-ui/plugins/auth-server/components/Configuration/Defaults/utils.js b/admin-ui/plugins/auth-server/components/Configuration/Defaults/utils.js new file mode 100644 index 0000000000..f07b17315d --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Configuration/Defaults/utils.js @@ -0,0 +1,10 @@ +export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'ERROR', 'WARN'] +export const LOG_LAYOUTS = ['text', 'json'] + +export const getLoggingInitialValues = (logging) => ({ + loggingLevel: logging?.loggingLevel, + loggingLayout: logging?.loggingLayout, + httpLoggingEnabled: logging?.httpLoggingEnabled, + disableJdkLogger: logging?.disableJdkLogger, + enabledOAuthAuditLogging: logging?.enabledOAuthAuditLogging, +}) diff --git a/admin-ui/plugins/auth-server/components/Configuration/Defaults/validations.js b/admin-ui/plugins/auth-server/components/Configuration/Defaults/validations.js new file mode 100644 index 0000000000..6518a08037 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Configuration/Defaults/validations.js @@ -0,0 +1,12 @@ +import * as Yup from 'yup' +import { LOG_LEVELS, LOG_LAYOUTS } from './utils' + +export const loggingValidationSchema = Yup.object({ + loggingLevel: Yup.string().oneOf(LOG_LEVELS, 'Invalid log level').required('Required!'), + loggingLayout: Yup.string().oneOf(LOG_LAYOUTS, 'Invalid log layout').required('Required!'), + httpLoggingEnabled: Yup.boolean().optional(), + disableJdkLogger: Yup.boolean().optional(), + enabledOAuthAuditLogging: Yup.boolean().optional(), +}) + +export default loggingValidationSchema diff --git a/admin-ui/plugins/auth-server/components/JsonViewer/JsonViewerDialog.js b/admin-ui/plugins/auth-server/components/JsonViewer/JsonViewerDialog.js index 42c700f831..31bc81bf17 100644 --- a/admin-ui/plugins/auth-server/components/JsonViewer/JsonViewerDialog.js +++ b/admin-ui/plugins/auth-server/components/JsonViewer/JsonViewerDialog.js @@ -1,9 +1,10 @@ import React from 'react' -import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap' +import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' import { useTranslation } from 'react-i18next' import JsonViewer from './JsonViewer' import PropTypes from 'prop-types' import customColors from '@/customColors' +import GluuFormFooter from 'Routes/Apps/Gluu/GluuFormFooter' const JsonViewerDialog = ({ isOpen, @@ -26,8 +27,17 @@ const JsonViewerDialog = ({ - - + + ) diff --git a/admin-ui/plugins/auth-server/components/Ssa/SsaAddPage.js b/admin-ui/plugins/auth-server/components/Ssa/SsaAddPage.js index 7bc081e2c5..d7a836fac1 100644 --- a/admin-ui/plugins/auth-server/components/Ssa/SsaAddPage.js +++ b/admin-ui/plugins/auth-server/components/Ssa/SsaAddPage.js @@ -1,9 +1,9 @@ -import React, { useEffect, useState, useCallback } from 'react' +import React, { useEffect, useState, useCallback, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useNavigate } from 'react-router' import { useTranslation } from 'react-i18next' import { useFormik } from 'formik' -import * as Yup from 'yup' +// Yup handled via schema file import debounce from 'lodash/debounce' import { CardBody, Card, Form, Col, Row, FormGroup } from 'Components' import { LocalizationProvider } from '@mui/x-date-pickers' @@ -16,7 +16,7 @@ import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' import GluuTypeAhead from 'Routes/Apps/Gluu/GluuTypeAhead' import GluuToogleRow from 'Routes/Apps/Gluu/GluuToogleRow' import GluuRemovableInputRow from 'Routes/Apps/Gluu/GluuRemovableInputRow' -import GluuCommitFooter from 'Routes/Apps/Gluu/GluuCommitFooter' +import GluuFormFooter from 'Routes/Apps/Gluu/GluuFormFooter' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import { SSA } from 'Utils/ApiResources' import { buildPayload } from 'Utils/PermChecker' @@ -25,14 +25,8 @@ import { getJsonConfig } from '../../redux/features/jsonConfigSlice' import { FETCHING_JSON_PROPERTIES } from '../../common/Constants' import CustomAttributesList from './CustomAttributesList' import { GRANT_TYPES, DEBOUNCE_DELAY } from './constants' - -const validationSchema = Yup.object({ - software_id: Yup.string(), - software_roles: Yup.array(), - description: Yup.string(), - org_id: Yup.string(), - grant_types: Yup.array(), -}) +import { ssaValidationSchema } from './validations' +import { getSsaInitialValues, toEpochSecondsFromDayjs } from './utils' const SsaAddPage = () => { const { t } = useTranslation() @@ -44,8 +38,22 @@ const SsaAddPage = () => { const [expirationDate, setExpirationDate] = useState(null) const [selectedAttributes, setSelectedAttributes] = useState([]) const [searchQuery, setSearchQuery] = useState('') + const [filteredQuery, setFilteredQuery] = useState('') const [modifiedFields, setModifiedFields] = useState({}) const [isSubmitting, setIsSubmitting] = useState(false) + const debouncedSetFilteredQuery = useRef( + debounce((value) => { + setFilteredQuery(value) + }, DEBOUNCE_DELAY), + ).current + + useEffect(() => { + return () => { + if (debouncedSetFilteredQuery && debouncedSetFilteredQuery.cancel) { + debouncedSetFilteredQuery.cancel() + } + } + }, [debouncedSetFilteredQuery]) const { savedConfig } = useSelector((state) => state.ssaReducer) const customAttributes = useSelector( @@ -55,18 +63,11 @@ const SsaAddPage = () => { SetTitle(t('titles.ssa_management')) const formik = useFormik({ - initialValues: { - software_id: '', - one_time_use: false, - org_id: '', - description: '', - software_roles: [], - rotate_ssa: false, - grant_types: [], - }, - validationSchema, + initialValues: getSsaInitialValues(), + validationSchema: ssaValidationSchema, enableReinitialize: true, - onSubmit: (values) => { + validateOnMount: true, + onSubmit: () => { setModal(true) }, }) @@ -85,10 +86,11 @@ const SsaAddPage = () => { }, [savedConfig, navigate, dispatch]) const handleSearchChange = useCallback( - debounce((value) => { + (value) => { setSearchQuery(value) - }, DEBOUNCE_DELAY), - [], + debouncedSetFilteredQuery(value) + }, + [debouncedSetFilteredQuery], ) const handleAttributeSelect = useCallback( @@ -103,7 +105,6 @@ const SsaAddPage = () => { const handleAttributeRemove = useCallback( (attribute) => { setSelectedAttributes((prev) => prev.filter((attr) => attr !== attribute)) - // Remove the custom attribute from formik values formik.setFieldValue(attribute, undefined) const newModifiedFields = { ...modifiedFields } delete newModifiedFields[attribute] @@ -119,9 +120,9 @@ const SsaAddPage = () => { // Get all form values including custom attributes const formValues = { ...formik.values } - // Add expiration if applicable - const timestamp = expirationDate?.getTime - formValues.expiration = isExpirable && timestamp ? Math.floor(timestamp / 1000) : null + if (!isExpirable) { + formValues.expiration = null + } const userAction = {} buildPayload(userAction, userMessage, formValues) @@ -156,6 +157,7 @@ const SsaAddPage = () => { errorMessage={formik.errors.software_id} showError={formik.errors.software_id && formik.touched.software_id} doc_category={SSA} + required /> { errorMessage={formik.errors.org_id} showError={formik.errors.org_id && formik.touched.org_id} doc_category={SSA} + required /> { lsize={6} rsize={3} value={isExpirable} - handler={() => setIsExpirable(!isExpirable)} + handler={() => { + const newValue = !isExpirable + setIsExpirable(newValue) + if (!newValue) { + setExpirationDate(null) + formik.setFieldValue('expiration', null) + } + }} errorMessage={formik.errors.expiration} showError={formik.errors.expiration && formik.touched.expiration} doc_category={SSA} @@ -252,7 +262,10 @@ const SsaAddPage = () => { setExpirationDate(date)} + onChange={(date) => { + setExpirationDate(date) + formik.setFieldValue('expiration', toEpochSecondsFromDayjs(date)) + }} disablePast /> @@ -277,11 +290,24 @@ const SsaAddPage = () => { - navigate('/auth-server/config/ssa')} + onCancel={() => { + formik.resetForm() + setSelectedAttributes([]) + setSearchQuery('') + setIsExpirable(false) + setExpirationDate(null) + }} + onApply={formik.handleSubmit} + disableBack={false} + disableCancel={!formik.dirty} + disableApply={!formik.isValid || !formik.dirty} + applyButtonType="button" + isLoading={isSubmitting} /> @@ -294,6 +320,7 @@ const SsaAddPage = () => { selectedAttributes={selectedAttributes} onAttributeSelect={handleAttributeSelect} searchQuery={searchQuery} + filteredQuery={filteredQuery} onSearchChange={handleSearchChange} /> diff --git a/admin-ui/plugins/auth-server/components/Ssa/utils.js b/admin-ui/plugins/auth-server/components/Ssa/utils.js new file mode 100644 index 0000000000..6145722a4f --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/utils.js @@ -0,0 +1,24 @@ +export const getSsaInitialValues = () => ({ + software_id: '', + one_time_use: false, + org_id: '', + description: '', + software_roles: [], + rotate_ssa: false, + grant_types: [], + expiration: null, +}) + +// Accepts a Dayjs object from MUI DatePicker and returns epoch seconds or null +export const toEpochSecondsFromDayjs = (dayjsValue) => { + try { + if (!dayjsValue) return null + const ms = dayjsValue.toDate().getTime() + if (Number.isFinite(ms)) { + return Math.floor(ms / 1000) + } + return null + } catch (e) { + return null + } +} diff --git a/admin-ui/plugins/auth-server/components/Ssa/validations.js b/admin-ui/plugins/auth-server/components/Ssa/validations.js new file mode 100644 index 0000000000..f86370488e --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/validations.js @@ -0,0 +1,14 @@ +import * as Yup from 'yup' + +export const ssaValidationSchema = Yup.object({ + software_id: Yup.string().required('Required!'), + org_id: Yup.string().required('Required!'), + description: Yup.string().optional(), + software_roles: Yup.array().optional(), + grant_types: Yup.array().optional(), + one_time_use: Yup.boolean().optional(), + rotate_ssa: Yup.boolean().optional(), + expiration: Yup.number().nullable().optional(), +}) + +export default ssaValidationSchema