diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 5524b64b..ecf953a0 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -4,9 +4,35 @@ import './App.css'; import {store} from './store'; import AuthWrapper from './components/AuthWrapper/AuthWrapper'; import ConfigLoader from './components/ConfigLoader/ConfigLoader'; +import {useSchemaLoader} from './hooks/useSchemaLoader'; import Menu from './components/Menu/Menu'; import {menuItems} from './config/menuItems'; +function AppContent() { + useSchemaLoader(); + + return ( +
+ +
+ +
+ + + } /> + } /> + {menuItems.map(item => ( + } /> + ))} + + +
+
+
+
+ ); +} + /** * Simple basename computation from URL path. * Basename is everything before the last path segment. @@ -35,24 +61,7 @@ function App() { return ( -
- -
- -
- - - } /> - } /> - {menuItems.map(item => ( - } /> - ))} - - -
-
-
-
+
); diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 8b0e08a5..a9594bcc 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -34,8 +34,7 @@ export const fetchConfiguration = async () => { }; export const fetchConfigurationSchema = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`, {credentials: 'include'}); - if (response.status === 401) throw new Error('UNAUTHORIZED'); + const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`); if (!response.ok) throw new Error('Failed to fetch configuration schema'); return response.json(); }; diff --git a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js index 37b05d63..8c1abb7e 100644 --- a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js +++ b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js @@ -1,15 +1,7 @@ import {useEffect} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import { - selectConfig, - selectConfigLoading, - selectConfigError, - selectSchema, - selectSchemaLoading, - selectSchemaError, - fetchConfig, - fetchSchema -} from '../../store/slices/configSlice'; +import {selectConfig, selectConfigLoading, selectConfigError, selectSchema, fetchConfig} from '../../store/slices/configSlice'; +import {selectIsAuthenticated} from '../../store/slices/userSlice'; import Button from '../Button/Button'; const ConfigLoader = ({children}) => { @@ -18,23 +10,16 @@ const ConfigLoader = ({children}) => { const configLoading = useSelector(selectConfigLoading); const configError = useSelector(selectConfigError); const schema = useSelector(selectSchema); - const schemaLoading = useSelector(selectSchemaLoading); - const schemaError = useSelector(selectSchemaError); + const isAuthenticated = useSelector(selectIsAuthenticated); - const loading = configLoading || schemaLoading; - const error = configError || schemaError; + const loading = configLoading; + const error = configError; useEffect(() => { - // Fetch config if not loaded - if (!config && !configLoading && !configError) { + if (isAuthenticated && !config && !configLoading && !configError) { dispatch(fetchConfig()); } - - // Fetch schema if not loaded (only once per session) - if (!schema && !schemaLoading && !schemaError) { - dispatch(fetchSchema()); - } - }, [config, configLoading, configError, schema, schemaLoading, schemaError, dispatch]); + }, [config, configLoading, configError, isAuthenticated, dispatch]); if (loading) { return ( diff --git a/AdminPanel/client/src/components/PasswordInputWithRequirements/PasswordInputWithRequirements.js b/AdminPanel/client/src/components/PasswordInputWithRequirements/PasswordInputWithRequirements.js index 8b4bc2ae..e803a3d1 100644 --- a/AdminPanel/client/src/components/PasswordInputWithRequirements/PasswordInputWithRequirements.js +++ b/AdminPanel/client/src/components/PasswordInputWithRequirements/PasswordInputWithRequirements.js @@ -1,31 +1,22 @@ -import React, {useState} from 'react'; +import {useState} from 'react'; import Input from '../Input/Input'; import PasswordRequirements from '../PasswordRequirements/PasswordRequirements'; -import {validatePasswordStrength} from '../../utils/passwordValidation'; +import {usePasswordValidation} from '../../utils/passwordValidation'; /** * Password input component with requirements display on focus * Matches DocSpace PasswordInput standard behavior */ -function PasswordInputWithRequirements({ - label, - value, - onChange, - placeholder, - description, - error, - settings, - ...props -}) { +function PasswordInputWithRequirements({label, value, onChange, placeholder, description, error, ...props}) { const [isFocused, setIsFocused] = useState(false); + const {isValid} = usePasswordValidation(value); const handleFocus = () => { setIsFocused(true); }; const handleBlur = () => { - const passwordValidation = validatePasswordStrength(value || ''); - if (passwordValidation.isValid || !value) { + if (isValid || !value) { setIsFocused(false); } }; @@ -35,7 +26,7 @@ function PasswordInputWithRequirements({
- +
); diff --git a/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.js b/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.js index 222170fe..fd913535 100644 --- a/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.js +++ b/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.js @@ -1,19 +1,47 @@ -import React, {useState} from 'react'; -import {getPasswordRequirementsStatus} from '../../utils/passwordValidation'; import SuccessGreenIcon from '../../assets/SuccessGreen.svg'; import FailRedIcon from '../../assets/FailRed.svg'; import styles from './PasswordRequirements.module.scss'; +import {useSelector} from 'react-redux'; +import {selectSchema} from '../../store/slices/configSlice'; +import {useMemo} from 'react'; +import {usePasswordValidation} from '../../utils/passwordValidation'; /** * Component that displays password requirements with progress bar * Matches DocSpace PasswordInput standard * @param {string} password - Password to validate * @param {boolean} isVisible - Whether to show the requirements (e.g., on focus) - * @param {Object} settings - Password settings (optional) */ -function PasswordRequirements({password, isVisible = false, settings}) { - const requirementsStatus = getPasswordRequirementsStatus(password || '', settings); - const {requirements, progress, isValid} = requirementsStatus; +function PasswordRequirements({password, isVisible = false}) { + const schema = useSelector(selectSchema); + const {invalidRules, isValid} = usePasswordValidation(password); + + const passwordValidation = schema?.properties?.adminPanel?.properties?.passwordValidation; + + const requirements = useMemo(() => { + const rules = [ + {key: 'minLength', format: 'passlength'}, + {key: 'hasDigit', format: 'passdigit'}, + {key: 'hasUppercase', format: 'passupper'}, + {key: 'hasSpecialChar', format: 'passspecial'} + ]; + + const invalidRulesSet = new Set(invalidRules); + + return rules.map(rule => { + const property = passwordValidation.properties[rule.key]; + const isValid = !invalidRulesSet.has(rule.key); + + return { + text: property?.description, + isValid + }; + }); + }, [passwordValidation, invalidRules]); + + const validRequirements = requirements.filter(req => req.isValid).length; + const totalRequirements = requirements.length; + const progress = (validRequirements / totalRequirements) * 100; const shouldShow = isVisible || (!isValid && password); @@ -24,25 +52,15 @@ function PasswordRequirements({password, isVisible = false, settings}) { return (
-
+
- -
Password must contain:
+ +
Password must:
    {requirements.map((requirement, index) => ( -
  • +
  • - {requirement.isValid ? ( - Success - ) : ( - Fail - )} + {requirement.isValid ? Success : Fail} {requirement.text}
  • @@ -53,4 +71,3 @@ function PasswordRequirements({password, isVisible = false, settings}) { } export default PasswordRequirements; - diff --git a/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.module.scss b/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.module.scss index 5d85b705..7a50a9ad 100644 --- a/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.module.scss +++ b/AdminPanel/client/src/components/PasswordRequirements/PasswordRequirements.module.scss @@ -36,7 +36,7 @@ } .requirementsTitle { - font-size: 11px; + font-size: 10px; font-weight: 500; color: #666; margin-bottom: 6px; @@ -54,7 +54,7 @@ display: flex; align-items: center; margin-bottom: 4px; - font-size: 12px; + font-size: 10px; line-height: 1.4; } @@ -63,9 +63,9 @@ } .bullet { - margin-right: 8px; - width: 14px; - height: 14px; + margin-right: 6px; + width: 12px; + height: 12px; display: flex; align-items: center; justify-content: center; diff --git a/AdminPanel/client/src/hooks/useFieldValidation.js b/AdminPanel/client/src/hooks/useFieldValidation.js index 43be1765..abc70c87 100644 --- a/AdminPanel/client/src/hooks/useFieldValidation.js +++ b/AdminPanel/client/src/hooks/useFieldValidation.js @@ -4,9 +4,6 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import {selectSchema, selectSchemaLoading, selectSchemaError} from '../store/slices/configSlice'; -// Cron expression with 6 space-separated fields (server-compatible) -const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/; - /** * Hook for field validation using backend schema * @returns {Object} { validateField, getFieldError, isLoading, error } @@ -25,7 +22,19 @@ export const useFieldValidation = () => { // Build AJV validator with custom and standard formats const ajv = new Ajv({allErrors: true, strict: false}); addFormats(ajv); // Add standard formats including email - ajv.addFormat('cron6', CRON6_REGEX); // Add custom cron6 format + + // Register formats from schema $defs.formats (regex strings) + const formats = schema?.$defs?.formats; + if (formats && typeof formats === 'object') { + for (const [name, patternString] of Object.entries(formats)) { + try { + const re = new RegExp(patternString); + ajv.addFormat(name, re); + } catch (e) { + console.warn('Invalid format regex in schema $defs.formats:', name, e.message); + } + } + } const validateFn = ajv.compile(schema); setValidator(() => validateFn); diff --git a/AdminPanel/client/src/hooks/useSchemaLoader.js b/AdminPanel/client/src/hooks/useSchemaLoader.js new file mode 100644 index 00000000..2b7e2940 --- /dev/null +++ b/AdminPanel/client/src/hooks/useSchemaLoader.js @@ -0,0 +1,27 @@ +import {useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {selectSchema, selectSchemaLoading, selectSchemaError, fetchSchema} from '../store/slices/configSlice'; + +/** + * Hook to load schema on app startup + * Fetches schema immediately when the hook is first used + */ +export const useSchemaLoader = () => { + const dispatch = useDispatch(); + const schema = useSelector(selectSchema); + const schemaLoading = useSelector(selectSchemaLoading); + const schemaError = useSelector(selectSchemaError); + + useEffect(() => { + // Fetch schema if not loaded (always fetch, no auth required) + if (!schema && !schemaLoading && !schemaError) { + dispatch(fetchSchema()); + } + }, [schema, schemaLoading, schemaError, dispatch]); + + return { + schema, + schemaLoading, + schemaError + }; +}; diff --git a/AdminPanel/client/src/pages/ChangePassword/ChangePassword.js b/AdminPanel/client/src/pages/ChangePassword/ChangePassword.js index 80d4101f..1e0f282b 100644 --- a/AdminPanel/client/src/pages/ChangePassword/ChangePassword.js +++ b/AdminPanel/client/src/pages/ChangePassword/ChangePassword.js @@ -5,7 +5,7 @@ import PageDescription from '../../components/PageDescription/PageDescription'; import Input from '../../components/Input/Input'; import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements'; -import {validatePasswordStrength} from '../../utils/passwordValidation'; +import {usePasswordValidation} from '../../utils/passwordValidation'; import styles from './ChangePassword.module.scss'; function ChangePassword() { @@ -15,21 +15,22 @@ function ChangePassword() { const [passwordError, setPasswordError] = useState(''); const [passwordSuccess, setPasswordSuccess] = useState(false); + const {isValid: newPasswordIsValid, isLoading} = usePasswordValidation(newPassword); + // Check if form can be submitted const canSubmit = () => { - if (!currentPassword || !newPassword || !confirmPassword) { + if (!currentPassword || !newPassword || !confirmPassword || isLoading) { return false; } - - const passwordValidation = validatePasswordStrength(newPassword); - if (!passwordValidation.isValid) { + + if (!newPasswordIsValid) { return false; } - + if (newPassword !== confirmPassword) { return false; } - + return true; }; @@ -37,28 +38,6 @@ function ChangePassword() { setPasswordError(''); setPasswordSuccess(false); - // Validation - if (!currentPassword) { - setPasswordError('Current password is required'); - throw new Error('Validation failed'); - } - - if (!newPassword) { - setPasswordError('New password is required'); - throw new Error('Validation failed'); - } - - const passwordValidation = validatePasswordStrength(newPassword); - if (!passwordValidation.isValid) { - setPasswordError(passwordValidation.errorMessage); - throw new Error('Validation failed'); - } - - if (newPassword !== confirmPassword) { - setPasswordError('New passwords do not match'); - throw new Error('Validation failed'); - } - try { await changePassword({currentPassword, newPassword}); setPasswordSuccess(true); @@ -111,9 +90,7 @@ function ChangePassword() { description='Re-enter your new password' />
    - {newPassword && confirmPassword && newPassword !== confirmPassword && validatePasswordStrength(newPassword).isValid && ( - 'Passwords don\'t match' - )} + {newPassword && confirmPassword && newPassword !== confirmPassword && newPasswordIsValid && "Passwords don't match"}
diff --git a/AdminPanel/client/src/pages/Setup/SetupPage.js b/AdminPanel/client/src/pages/Setup/SetupPage.js index 219bcb3b..4a8d8170 100644 --- a/AdminPanel/client/src/pages/Setup/SetupPage.js +++ b/AdminPanel/client/src/pages/Setup/SetupPage.js @@ -5,7 +5,7 @@ import {fetchUser} from '../../store/slices/userSlice'; import Input from '../../components/Input/Input'; import Button from '../../components/Button/Button'; import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements'; -import {validatePasswordStrength} from '../../utils/passwordValidation'; +import {usePasswordValidation} from '../../utils/passwordValidation'; import styles from './styles.module.css'; export default function Setup() { @@ -16,55 +16,28 @@ export default function Setup() { const dispatch = useDispatch(); const buttonRef = useRef(); + const {isValid: passwordIsValid, isLoading} = usePasswordValidation(password); + // Check if form can be submitted const canSubmit = () => { - if (!bootstrapToken.trim() || !password || !confirmPassword) { + if (!bootstrapToken.trim() || !password || !confirmPassword || isLoading) { return false; } - - const passwordValidation = validatePasswordStrength(password); - if (!passwordValidation.isValid) { + + // Check if password meets all requirements using hook + if (!passwordIsValid) { return false; } - + if (password !== confirmPassword) { return false; } - + return true; }; const handleSubmit = async () => { setErrors({}); - - // Validate all fields - const newErrors = {}; - - if (!bootstrapToken.trim()) { - newErrors.bootstrapToken = 'Bootstrap token is required'; - } - - if (!password) { - newErrors.password = 'Password is required'; - } else { - const passwordValidation = validatePasswordStrength(password); - if (!passwordValidation.isValid) { - newErrors.password = passwordValidation.errorMessage; - } - } - - if (!confirmPassword) { - newErrors.confirmPassword = 'Please confirm your password'; - } else if (password !== confirmPassword) { - newErrors.confirmPassword = 'Passwords do not match'; - } - - // If there are validation errors, show them - if (Object.keys(newErrors).length > 0) { - setErrors(newErrors); - throw new Error('Validation failed'); - } - try { await setupAdminPassword({bootstrapToken, password}); // Wait for cookie to be set and verify authentication works @@ -129,9 +102,7 @@ export default function Setup() { onKeyDown={handleKeyDown} />
- {password && confirmPassword && password !== confirmPassword && validatePasswordStrength(password).isValid && ( - 'Passwords don\'t match' - )} + {password && confirmPassword && password !== confirmPassword && passwordIsValid && "Passwords don't match"}
diff --git a/AdminPanel/client/src/utils/passwordValidation.js b/AdminPanel/client/src/utils/passwordValidation.js index 7aa9f847..138596ab 100644 --- a/AdminPanel/client/src/utils/passwordValidation.js +++ b/AdminPanel/client/src/utils/passwordValidation.js @@ -1,113 +1,31 @@ -/** - * Password validation utility - * Validates password strength according to security requirements - */ - -// Default password settings matching DocSpace standard -const DEFAULT_PASSWORD_SETTINGS = { - minLength: 8, - maxLength: 128, - upperCase: true, - digits: true, - specSymbols: true -}; +import {useFieldValidation} from '../hooks/useFieldValidation'; +import {useMemo} from 'react'; /** - * Validates password strength + * Hook for password validation * @param {string} password - Password to validate - * @param {Object} settings - Password settings (optional) - * @returns {Object} Validation result with isValid boolean and error message + * @returns {Object} { isValid, errorMessage, invalidRules, isLoading, error } */ -export function validatePasswordStrength(password, settings = DEFAULT_PASSWORD_SETTINGS) { - const errors = []; +export function usePasswordValidation(password) { + const {validateField, isLoading} = useFieldValidation(); - // Check minimum length - if (password.length < settings.minLength) { - errors.push(`Password must be at least ${settings.minLength} characters long`); - } + const validationResult = useMemo(() => { + const rules = ['minLength', 'hasDigit', 'hasUppercase', 'hasSpecialChar']; - // Check maximum length - if (password.length > settings.maxLength) { - errors.push(`Password must not exceed ${settings.maxLength} characters`); - } + const invalidRules = rules.filter(rule => { + const fieldPath = `adminPanel.passwordValidation.${rule}`; + const error = validateField(fieldPath, password || ''); + return !!error; + }); - // Check for at least one digit - if (settings.digits && !/\d/.test(password)) { - errors.push('Password must contain at least one digit'); - } - - // Check for at least one uppercase letter - if (settings.upperCase && !/[A-Z]/.test(password)) { - errors.push('Password must contain at least one uppercase letter'); - } - - // Check for at least one special character - if (settings.specSymbols && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { - errors.push('Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)'); - } + return { + isValid: invalidRules.length === 0, + invalidRules + }; + }, [validateField, password]); return { - isValid: errors.length === 0, - errors: errors, - errorMessage: errors.length > 0 ? errors.join('. ') : null + ...validationResult, + isLoading }; } - -/** - * Gets detailed password requirements with validation status - * @param {string} password - Password to validate - * @param {Object} settings - Password settings (optional) - * @returns {Object} Requirements status with progress and requirements array - */ -export function getPasswordRequirementsStatus(password, settings = DEFAULT_PASSWORD_SETTINGS) { - const requirements = []; - - // Length requirement - requirements.push({ - text: `from ${settings.minLength} to ${settings.maxLength} characters`, - isValid: password.length >= settings.minLength && password.length <= settings.maxLength - }); - - // Digits requirement - if (settings.digits) { - requirements.push({ - text: 'digits', - isValid: /\d/.test(password) - }); - } - - // Uppercase requirement - if (settings.upperCase) { - requirements.push({ - text: 'capital letters', - isValid: /[A-Z]/.test(password) - }); - } - - // Special symbols requirement - if (settings.specSymbols) { - requirements.push({ - text: 'special characters (!@#$%^&*)', - isValid: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password) - }); - } - - // Calculate progress (0-100) - const validRequirements = requirements.filter(req => req.isValid).length; - const totalRequirements = requirements.length; - const progress = totalRequirements > 0 ? (validRequirements / totalRequirements) * 100 : 0; - - return { - requirements, - progress, - isValid: progress === 100 - }; -} - -/** - * Gets password strength requirements as a readable string - * @returns {string} Requirements description - */ -export function getPasswordRequirements() { - return 'Password must be 8-128 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character'; -} diff --git a/AdminPanel/server/sources/routes/adminpanel/router.js b/AdminPanel/server/sources/routes/adminpanel/router.js index 1e3954fe..53a6b19e 100644 --- a/AdminPanel/server/sources/routes/adminpanel/router.js +++ b/AdminPanel/server/sources/routes/adminpanel/router.js @@ -8,12 +8,44 @@ const bootstrap = require('../../bootstrap'); const adminPanelJwtSecret = require('../../jwtSecret'); const tenantManager = require('../../../../../Common/sources/tenantManager'); const commonDefines = require('../../../../../Common/sources/commondefines'); +const {validateScoped} = require('../config/config.service'); const router = express.Router(); router.use(express.json()); router.use(cookieParser()); +/** + * Validates password against all requirements using existing config service + * @param {operationContext} ctx - Operation context + * @param {string} password - Password to validate + * @returns {Object} Validation result with isValid boolean and error messages + */ +function validatePassword(ctx, password) { + const testData = { + adminPanel: { + passwordValidation: { + minLength: password, + hasDigit: password, + hasUppercase: password, + hasSpecialChar: password + } + } + }; + + const result = validateScoped(ctx, testData); + + if (result.value) { + return { + isValid: true + }; + } + + return { + isValid: false + }; +} + /** * Create session cookie with standard options * @param {import('express').Response} res - Express response @@ -106,12 +138,9 @@ router.post('/setup', async (req, res) => { return res.status(400).json({error: 'Password is required'}); } - if (password.length < passwordManager.PASSWORD_MIN_LENGTH) { - return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`}); - } - - if (password.length > passwordManager.PASSWORD_MAX_LENGTH) { - return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`}); + const passwordValidationResult = validatePassword(ctx, password); + if (!passwordValidationResult.isValid) { + return res.status(400).json({error: 'Password is too weak'}); } await passwordManager.saveAdminPassword(ctx, password); @@ -143,12 +172,9 @@ router.post('/change-password', requireAuth, async (req, res) => { return res.status(400).json({error: 'Current password and new password are required'}); } - if (newPassword.length < passwordManager.PASSWORD_MIN_LENGTH) { - return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`}); - } - - if (newPassword.length > passwordManager.PASSWORD_MAX_LENGTH) { - return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`}); + const passwordValidationResult = validatePassword(ctx, newPassword); + if (!passwordValidationResult.isValid) { + return res.status(400).json({error: 'Password is too weak'}); } if (currentPassword === newPassword) { diff --git a/AdminPanel/server/sources/routes/config/config.service.js b/AdminPanel/server/sources/routes/config/config.service.js index a5fbd3c9..1f15531a 100644 --- a/AdminPanel/server/sources/routes/config/config.service.js +++ b/AdminPanel/server/sources/routes/config/config.service.js @@ -39,7 +39,6 @@ const supersetSchema = require('../../../../../Common/config/schemas/config.sche const {deriveSchemaForScope, X_SCOPE_KEYWORD} = require('./config.schema.utils'); // Constants -const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/; const AJV_CONFIG = {allErrors: true, strict: false}; const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: true}; @@ -49,7 +48,14 @@ const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: tru */ function registerAjvExtras(instance) { instance.addKeyword({keyword: X_SCOPE_KEYWORD, schemaType: ['string', 'array'], errors: false}); - instance.addFormat('cron6', CRON6_REGEX); + + const formats = supersetSchema?.$defs?.formats; + if (formats && typeof formats === 'object') { + for (const [name, patternString] of Object.entries(formats)) { + const re = new RegExp(patternString); + instance.addFormat(name, re); + } + } } /** @@ -114,13 +120,4 @@ function getScopedConfig(ctx) { return configCopy; } -/** - * Returns the derived per-scope schema for ctx (admin or tenant). - * @param {operationContext} ctx - * @returns {object} - */ -function getScopedSchema(ctx) { - return isAdminScope(ctx) ? adminSchema : tenantSchema; -} - -module.exports = {validateScoped, getScopedConfig, getScopedSchema}; +module.exports = {validateScoped, getScopedConfig}; diff --git a/AdminPanel/server/sources/routes/config/router.js b/AdminPanel/server/sources/routes/config/router.js index 22a3b9e7..3e83d80e 100644 --- a/AdminPanel/server/sources/routes/config/router.js +++ b/AdminPanel/server/sources/routes/config/router.js @@ -4,10 +4,11 @@ const express = require('express'); const bodyParser = require('body-parser'); const tenantManager = require('../../../../../Common/sources/tenantManager'); const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager'); -const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service'); +const {getScopedConfig, validateScoped} = require('./config.service'); const {validateJWT} = require('../../middleware/auth'); const cookieParser = require('cookie-parser'); const utils = require('../../../../../Common/sources/utils'); +const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json'); const router = express.Router(); router.use(cookieParser()); @@ -35,18 +36,8 @@ router.get('/', validateJWT, async (req, res) => { } }); -router.get('/schema', validateJWT, async (req, res) => { - const ctx = req.ctx; - try { - ctx.logger.info('config schema start'); - const schema = getScopedSchema(ctx); - res.json(schema); - } catch (error) { - ctx.logger.error('Config schema error: %s', error.stack); - res.status(500).json({error: 'Internal server error'}); - } finally { - ctx.logger.info('config schema end'); - } +router.get('/schema', async (_req, res) => { + res.json(supersetSchema); }); router.patch('/', validateJWT, rawFileParser, async (req, res) => { diff --git a/Common/config/schemas/config.schema.json b/Common/config/schemas/config.schema.json index a132231c..37550d6d 100644 --- a/Common/config/schemas/config.schema.json +++ b/Common/config/schemas/config.schema.json @@ -5,6 +5,15 @@ "description": "Superset schema with x-scope markers. Use at runtime to derive per-scope schemas.", "type": "object", "additionalProperties": false, + "$defs": { + "formats": { + "cron6": "^\\s*\\S+(?:\\s+\\S+){5}\\s*$", + "passlength": "^.{8,128}$", + "passdigit": ".*\\d.*", + "passupper": ".*[A-Z].*", + "passspecial": ".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*" + } + }, "properties": { "aiSettings": { "type": "object", @@ -297,103 +306,141 @@ } } }, - "notification": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "rules": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "licenseExpirationWarning": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, - "policies": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "repeatInterval": { - "type": "string", - "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", - "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", - "x-scope": ["admin", "tenant"] - } - } - } - } - }, - "licenseExpirationError": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, - "policies": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "repeatInterval": { - "type": "string", - "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", - "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", - "x-scope": ["admin", "tenant"] - } - } - } - } - }, - "licenseLimitEdit": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, - "policies": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "repeatInterval": { - "type": "string", - "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", - "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", - "x-scope": ["admin", "tenant"] - } - } - } - } - }, - "licenseLimitLiveViewer": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, - "policies": { - "type": "object", - "additionalProperties": false, - "x-scope": ["admin", "tenant"], - "properties": { - "repeatInterval": { - "type": "string", - "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", - "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", - "x-scope": ["admin", "tenant"] - } - } - } - } - } - } - } - } - } + "notification": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "rules": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "licenseExpirationWarning": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, + "policies": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "repeatInterval": { + "type": "string", + "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", + "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", + "x-scope": ["admin", "tenant"] + } + } + } + } + }, + "licenseExpirationError": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, + "policies": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "repeatInterval": { + "type": "string", + "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", + "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", + "x-scope": ["admin", "tenant"] + } + } + } + } + }, + "licenseLimitEdit": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, + "policies": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "repeatInterval": { + "type": "string", + "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", + "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", + "x-scope": ["admin", "tenant"] + } + } + } + } + }, + "licenseLimitLiveViewer": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "enable": {"type": "boolean", "x-scope": ["admin", "tenant"]}, + "policies": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "repeatInterval": { + "type": "string", + "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", + "description": "Repeat interval in time format (e.g., '1d', '1h', '30m')", + "x-scope": ["admin", "tenant"] + } + } + } + } + } + } + } + } + }, + "adminPanel": { + "type": "object", + "additionalProperties": false, + "x-scope": ["admin", "tenant"], + "properties": { + "passwordValidation": { + "type": "object", + "x-scope": ["admin", "tenant"], + "description": "Password validation requirements using custom format types", + "properties": { + "minLength": { + "type": "string", + "format": "passlength", + "description": "be at least 8 characters long", + "x-scope": ["admin", "tenant"] + }, + "hasDigit": { + "type": "string", + "format": "passdigit", + "description": "contain at least one digit", + "x-scope": ["admin", "tenant"] + }, + "hasUppercase": { + "type": "string", + "format": "passupper", + "description": "contain at least one uppercase letter", + "x-scope": ["admin", "tenant"] + }, + "hasSpecialChar": { + "type": "string", + "format": "passspecial", + "description": "contain at least one special character", + "x-scope": ["admin", "tenant"] + } + } + } + } + } } }