[bug] Validate password strength on the backend; Fix bug 77233

This commit is contained in:
PauI Ostrovckij
2025-10-20 12:27:36 +03:00
parent 924595cbe4
commit 585b06d3a9
15 changed files with 361 additions and 401 deletions

View File

@ -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 (
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div>
</AuthWrapper>
</div>
);
}
/**
* Simple basename computation from URL path.
* Basename is everything before the last path segment.
@ -35,24 +61,7 @@ function App() {
return (
<Provider store={store}>
<BrowserRouter basename={basename}>
<div className='app'>
<AuthWrapper>
<div className='appLayout'>
<Menu />
<div className='mainContent'>
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}
</Routes>
</ConfigLoader>
</div>
</div>
</AuthWrapper>
</div>
<AppContent />
</BrowserRouter>
</Provider>
);

View File

@ -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();
};

View File

@ -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 (

View File

@ -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({
<div style={{position: 'relative'}}>
<Input
label={label}
type="password"
type='password'
value={value}
onChange={onChange}
placeholder={placeholder}
@ -45,11 +36,7 @@ function PasswordInputWithRequirements({
onBlur={handleBlur}
{...props}
/>
<PasswordRequirements
password={value}
isVisible={isFocused}
settings={settings}
/>
<PasswordRequirements password={value} isVisible={isFocused} />
</div>
</div>
);

View File

@ -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 (
<div className={styles.requirementsContainer}>
<div className={styles.progressBar}>
<div
className={`${styles.progressFill} ${isValid ? styles.progressComplete : ''}`}
style={{width: `${progress}%`}}
/>
<div className={`${styles.progressFill} ${isValid ? styles.progressComplete : ''}`} style={{width: `${progress}%`}} />
</div>
<div className={styles.requirementsTitle}>Password must contain:</div>
<div className={styles.requirementsTitle}>Password must:</div>
<ul className={styles.requirementsList}>
{requirements.map((requirement, index) => (
<li
key={index}
className={`${styles.requirementItem} ${requirement.isValid ? styles.valid : styles.invalid}`}
>
<li key={index} className={`${styles.requirementItem} ${requirement.isValid ? styles.valid : styles.invalid}`}>
<span className={styles.bullet}>
{requirement.isValid ? (
<img src={SuccessGreenIcon} alt="Success" />
) : (
<img src={FailRedIcon} alt="Fail" />
)}
{requirement.isValid ? <img src={SuccessGreenIcon} alt='Success' /> : <img src={FailRedIcon} alt='Fail' />}
</span>
<span className={styles.text}>{requirement.text}</span>
</li>
@ -53,4 +71,3 @@ function PasswordRequirements({password, isVisible = false, settings}) {
}
export default PasswordRequirements;

View File

@ -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;

View File

@ -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);

View File

@ -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
};
};

View File

@ -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'
/>
<div className={styles.passwordMismatch}>
{newPassword && confirmPassword && newPassword !== confirmPassword && validatePasswordStrength(newPassword).isValid && (
'Passwords don\'t match'
)}
{newPassword && confirmPassword && newPassword !== confirmPassword && newPasswordIsValid && "Passwords don't match"}
</div>
</div>

View File

@ -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}
/>
<div className={styles.passwordMismatch}>
{password && confirmPassword && password !== confirmPassword && validatePasswordStrength(password).isValid && (
'Passwords don\'t match'
)}
{password && confirmPassword && password !== confirmPassword && passwordIsValid && "Passwords don't match"}
</div>
</div>

View File

@ -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';
}

View File

@ -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) {

View File

@ -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};

View File

@ -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) => {

View File

@ -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"]
}
}
}
}
}
}
}