Merge branch 'feature/admin-panel-3' into feature/admin-panel2

This commit is contained in:
PauI Ostrovckij
2025-09-04 18:16:00 +03:00
17 changed files with 678 additions and 164 deletions

View File

@ -74,14 +74,14 @@ export const fetchCurrentUser = async () => {
return response.json();
};
export const login = async secret => {
export const login = async ({tenantName, secret}) => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({secret})
body: JSON.stringify({tenantName, secret})
});
if (!response.ok) {

View File

@ -7,7 +7,9 @@ import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {configurationSections, ROLES} from '../../config/configurationSchema';
import {selectUser} from '../../store/slices/userSlice';
import ExpandableSection from '../ExpandableSection';
import ConfigurationField from '../ConfigurationInput';
import ConfigurationInput from '../ConfigurationInput';
import SelectField from '../SelectField';
import JsonField from '../JsonField';
import Button from '../Button';
import styles from './styles.module.css';
@ -23,17 +25,6 @@ export default function Configuration() {
// Cron expression with 6 space-separated fields (server-compatible)
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
/**
* Builds an Ajv validator instance for the provided JSON Schema.
* @param {object} schema - Derived per-scope JSON schema
* @returns {Ajv.ValidateFunction}
*/
const buildValidator = schema => {
const ajv = new Ajv({allErrors: true, strict: false});
ajv.addFormat('cron6', CRON6_REGEX);
return ajv.compile(schema);
};
/**
* Converts Ajv errors to a field error map suitable for UI display.
* @param {Ajv.ErrorObject[]} errors
@ -72,6 +63,16 @@ export default function Configuration() {
useEffect(() => {
const loadConfiguration = async () => {
try {
/**
* Builds an Ajv validator instance for the provided JSON Schema.
* @param {object} schema - Derived per-scope JSON schema
* @returns {Ajv.ValidateFunction}
*/
const buildValidator = schema => {
const ajv = new Ajv({allErrors: true, strict: false});
ajv.addFormat('cron6', CRON6_REGEX);
return ajv.compile(schema);
};
setLoading(true);
setError(null);
// Fetch config and schema in parallel
@ -81,7 +82,18 @@ export default function Configuration() {
const initialValues = {};
filteredSections.forEach(section => {
section.fields.forEach(field => {
initialValues[field.path] = getNestedValue(data, field.path, '');
let value = getNestedValue(data, field.path, '');
// Stringify JSON values for json type fields
if (field.type === 'json' && value !== '') {
try {
value = JSON.stringify(value, null, 2);
} catch (error) {
console.warn(`Failed to stringify JSON for field ${field.path}:`, error);
}
}
initialValues[field.path] = value;
});
});
setFieldValues(initialValues);
@ -97,7 +109,7 @@ export default function Configuration() {
};
loadConfiguration();
}, []);
}, [filteredSections, CRON6_REGEX]);
const handleFieldChange = (path, value) => {
setFieldValues(prev => ({
@ -127,7 +139,23 @@ export default function Configuration() {
const changedObjects = section.fields.map(field => {
const obj = {};
obj[field.path] = fieldValues[field.path];
let value = fieldValues[field.path];
// Parse JSON values for json type fields
if (field.type === 'json') {
try {
value = JSON.parse(value);
} catch (error) {
// If JSON parsing fails, keep the string value and let backend validation handle it
console.warn(`Failed to parse JSON for field ${field.path}:`, error);
}
}
if (field.type === 'checkbox') {
value = Boolean(value);
}
obj[field.path] = value;
return obj;
});
@ -149,15 +177,41 @@ export default function Configuration() {
try {
await updateConfiguration(mergedConfig);
} catch (error) {
console.log('error777', JSON.stringify(error));
// Handle validation errors from backend
if (error.error && error.error.details && Array.isArray(error.error.details)) {
const errors = {};
error.error.details.forEach(detail => {
if (detail.path && detail.message) {
// Join the path array to create the field path
const fieldPath = detail.path.join('.');
errors[fieldPath] = detail.message;
// Find the field that contains this error path
let fieldPath = null;
// Check each field in the current section to see if the error path starts with the field path
section.fields.forEach(field => {
const fieldPathParts = field.path.split('.');
const errorPathParts = detail.path;
// Check if the error path starts with the field path
if (fieldPathParts.length <= errorPathParts.length) {
let matches = true;
for (let i = 0; i < fieldPathParts.length; i++) {
if (fieldPathParts[i] !== errorPathParts[i]) {
matches = false;
break;
}
}
if (matches) {
fieldPath = field.path;
}
}
});
// If we found a matching field, use it; otherwise use the full path
if (fieldPath) {
errors[fieldPath] = detail.message;
} else {
// Fallback: use the full path
errors[detail.path.join('.')] = detail.message;
}
}
});
setFieldErrors(prev => ({...prev, ...errors}));
@ -182,20 +236,36 @@ export default function Configuration() {
{filteredSections.map((section, index) => {
return (
<ExpandableSection key={index} title={section.title}>
{section.fields.map(field => (
<ConfigurationField
key={field.path}
label={field.label}
value={fieldValues[field.path] || ''}
onChange={value => handleFieldChange(field.path, value)}
type={field.type}
error={fieldErrors[field.path]}
min={field.min}
max={field.max}
options={field.options}
description={field.description}
/>
))}
{section.fields.map(field => {
// Select component based on type
let FieldComponent;
switch (field.type) {
case 'select':
FieldComponent = SelectField;
break;
case 'json':
FieldComponent = JsonField;
break;
default:
FieldComponent = ConfigurationInput;
break;
}
return (
<FieldComponent
key={field.path}
label={field.label}
value={fieldValues[field.path] || ''}
onChange={value => handleFieldChange(field.path, value)}
type={field.type}
error={fieldErrors[field.path]}
min={field.min}
max={field.max}
options={field.options}
// description={field.description}
/>
);
})}
<Button onClick={() => handleSaveSection(section.title)} errorText='FAILED'>
SAVE

View File

@ -10,32 +10,22 @@ export default function ConfigurationInput({
error = null,
min = null,
max = null,
options = [],
description = null
}) {
const renderInput = () => {
if (type === 'select') {
return (
<select value={value} onChange={e => onChange(e.target.value)} className={`${styles.input} ${error ? styles.inputError : ''}`}>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
return <Input type={type} value={value} onChange={onChange} placeholder={placeholder} error={error} min={min} max={max} />;
};
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
{renderInput()}
{description && <div className={styles.description}>{description}</div>}
{error && <div className={styles.errorMessage}>{error}</div>}
<Input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
min={min}
max={max}
error={error}
/>
{/* {error && <div className={styles.errorMessage}>{error}</div>} */}
</div>
</div>
);

View File

@ -15,6 +15,7 @@
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
@ -27,38 +28,7 @@
max-width: 300px;
}
.input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.inputError {
border-color: #dc3545;
}
.inputError:focus {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}
.description {
color: #6c757d;
font-size: 12px;
font-style: italic;
margin-top: 2px;
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}
}

View File

@ -1,6 +1,8 @@
import styles from './styles.module.css';
export default function Input({
_label,
_description,
value,
onChange,
placeholder = '',
@ -11,6 +13,18 @@ export default function Input({
min = null,
max = null
}) {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={Boolean(value)}
onChange={e => onChange(e.target.checked)}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
/>
);
}
return (
<input
type={type}

View File

@ -1,3 +1,16 @@
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
}
.input {
padding: 11px 20px;
border: 1px solid rgb(170, 170, 170);
@ -26,3 +39,17 @@
.inputError {
border-color: #dc3545;
}
.description {
font-size: 11px;
color: #666;
margin: 0;
line-height: 1.3;
}
.error {
font-size: 11px;
color: #dc3545;
margin: 0;
line-height: 1.3;
}

View File

@ -0,0 +1,81 @@
import {useState, useEffect} from 'react';
import styles from './styles.module.css';
export default function JsonField({
label,
value,
onChange,
placeholder = '',
error = null,
description = null
}) {
const [jsonError, setJsonError] = useState(null);
const [isValidJson, setIsValidJson] = useState(true);
// Validate JSON on every change
useEffect(() => {
if (value.trim() === '') {
setJsonError(null);
setIsValidJson(true);
return;
}
try {
JSON.parse(value);
setJsonError(null);
setIsValidJson(true);
} catch (err) {
setJsonError(`Invalid JSON: ${err.message}`);
setIsValidJson(false);
}
}, [value]);
const formatJson = () => {
if (value.trim() !== '') {
try {
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);
onChange(formatted);
} catch (err) {
// If JSON is invalid, don't format
console.warn('Cannot format invalid JSON:', err);
}
}
};
const lines = value.split('\n');
const lineNumbers = lines.map((_, index) => index + 1);
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
<div className={styles.jsonContainer}>
<div className={styles.lineNumbers}>
{lineNumbers.map(num => (
<div key={num} className={styles.lineNumber}>{num}</div>
))}
</div>
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={`${styles.textarea} ${error || jsonError ? styles.inputError : ''}`}
rows={10}
spellCheck={false}
/>
<button
type="button"
className={styles.formatButton}
onClick={formatJson}
disabled={!isValidJson}
title="Format JSON"
>
Format
</button>
</div>
{(error || jsonError) && <div className={styles.errorMessage}>{error || jsonError}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 600px;
}
.jsonContainer {
display: flex;
position: relative;
}
.lineNumbers {
background: #f8f9fa;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
padding: 8px 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
color: #6c757d;
user-select: none;
min-width: 40px;
text-align: right;
}
.lineNumber {
height: 1.4em;
display: flex;
align-items: center;
justify-content: flex-end;
}
.textarea {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 0 4px 4px 0;
border-left: none;
font-size: 14px;
width: 100%;
box-sizing: border-box;
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 200px;
line-height: 1.4;
flex: 1;
outline: none;
}
.textarea:focus {
outline: none !important;
box-shadow: none !important;
}
.jsonContainer:focus-within .lineNumbers {
border-color: #007bff;
outline: none;
}
.inputError {
border-color: #dc3545;
}
.formatButton {
position: absolute;
bottom: 8px;
right: 8px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.formatButton:hover {
opacity: 1;
}
.formatButton:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.5;
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -0,0 +1,30 @@
import styles from './styles.module.css';
export default function SelectField({
label,
value,
onChange,
options = [],
error = null,
description = null
}) {
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
<select
value={value}
onChange={e => onChange(e.target.value)}
className={`${styles.select} ${error ? styles.inputError : ''}`}
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* {error && <div className={styles.errorMessage}>{error}</div>} */}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 300px;
}
.select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.inputError {
border-color: #dc3545;
}
.inputError:focus {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -37,6 +37,20 @@ export const configurationSections = [
min: 0,
roles: [ROLES.ADMIN],
description: 'Number of files to remove at once (admin only)'
},
{
path: 'services.CoAuthoring.expire.sessionidle',
label: 'Session Idle Timeout',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Session idle timeout (e.g., "1h", "30m")'
},
{
path: 'services.CoAuthoring.expire.sessionabsolute',
label: 'Session Absolute Timeout',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Session absolute timeout (e.g., "30d", "24h")'
}
]
},
@ -70,6 +84,88 @@ export const configurationSections = [
max: 104857600,
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Maximum number of bytes allowed for download (max: 100MB)'
},
{
path: 'FileConverter.converter.inputLimits',
label: 'Input Limits',
type: 'json',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'File type limits for conversion. Format: [{"type": "docx;dotx", "zip": {"uncompressed": "50MB", "template": "*.xml"}}]'
}
]
},
{
title: 'WOPI Configuration',
fields: [
{
path: 'wopi.enable',
label: 'Enable WOPI',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Enable WOPI (Web Application Open Platform Interface) support'
}
]
},
{
title: 'Email Configuration',
fields: [
{
path: 'email.smtpServerConfiguration.host',
label: 'SMTP Host',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP server hostname'
},
{
path: 'email.smtpServerConfiguration.port',
label: 'SMTP Port',
type: 'number',
min: 1,
max: 65535,
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP server port number'
},
{
path: 'email.smtpServerConfiguration.auth.user',
label: 'SMTP Username',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP authentication username'
},
{
path: 'email.smtpServerConfiguration.auth.pass',
label: 'SMTP Password',
type: 'password',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP authentication password'
},
{
path: 'email.connectionConfiguration.disableFileAccess',
label: 'Disable File Access',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Disable file access for email connections'
},
{
path: 'email.connectionConfiguration.disableUrlAccess',
label: 'Disable URL Access',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Disable URL access for email connections'
},
{
path: 'email.contactDefaults.from',
label: 'Default From Email',
type: 'email',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Default sender email address'
},
{
path: 'email.contactDefaults.to',
label: 'Default To Email',
type: 'email',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Default recipient email address'
}
]
}

View File

@ -6,6 +6,7 @@ import Button from '../../components/Button';
import styles from './styles.module.css';
export default function Login() {
const [tenantName, setTenantName] = useState('');
const [secret, setSecret] = useState('');
const [error, setError] = useState('');
const dispatch = useDispatch();
@ -15,7 +16,7 @@ export default function Login() {
setError('');
try {
await dispatch(loginUser(secret)).unwrap();
await dispatch(loginUser({tenantName, secret})).unwrap();
} catch (error) {
setError(error || 'Invalid credentials. Please try again.');
throw error; // Re-throw to trigger error state in Button component
@ -40,11 +41,22 @@ export default function Login() {
<div className={styles.form}>
<div className={styles.inputGroup}>
<Input
label='Secret Key'
type='text'
value={tenantName}
onChange={setTenantName}
placeholder='Enter your tenant name'
description='The name of your tenant organization'
onKeyDown={handleKeyDown}
/>
</div>
<div className={styles.inputGroup}>
<Input
type='password'
value={secret}
onChange={setSecret}
placeholder='Enter your secret key'
description='The secret key associated with your tenant'
error={error}
onKeyDown={handleKeyDown}
/>

View File

@ -9,9 +9,9 @@ export const fetchUser = createAsyncThunk('user/fetchUser', async (_, {rejectWit
}
});
export const loginUser = createAsyncThunk('user/loginUser', async (secret, {rejectWithValue}) => {
export const loginUser = createAsyncThunk('user/loginUser', async ({tenantName, secret}, {rejectWithValue}) => {
try {
const response = await login(secret);
const response = await login({tenantName, secret});
return response;
} catch (error) {
return rejectWithValue(error.message);