[CB] New and repeat passwords can be different (#4234)

* dbeaver/pro#8572 fixes password validity on blur

* Revert "dbeaver/pro#8572 fixes password validity on blur"

This reverts commit cefd174254.

* adds context to the form for correct validation + handles validation on change also to keep the actual validity state

* adds context check to prevent validation inconsistent behavior

* adds revalidate to handle depended validations cases

* adds useFormCustomInputValidation

* cleanup

* uses useFormCustomInputValidation to avoid regression after refactor
This commit is contained in:
sergeyteleshev
2026-03-31 14:07:47 +02:00
committed by GitHub
parent 9b4c874e51
commit 34b4556773
11 changed files with 118 additions and 69 deletions
@@ -1,22 +1,22 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { useContext, useEffect, useRef } from 'react';
import { ExecutorInterrupter } from '@cloudbeaver/core-executor';
import { useEffect, useRef } from 'react';
import { useTranslate } from '../localization/useTranslate.js';
import { useExecutor } from '../useExecutor.js';
import { FormContext } from './FormContext.js';
export interface ICustomInputValidation<TType extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement> {
ref: React.RefObject<TType | null>;
revalidate: () => void;
revalidateAndReport: (element: TType) => boolean;
}
export function useCustomInputValidation<T = void, TType extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(
validation: (value: T) => string | null,
): React.RefObject<TType | null> {
const context = useContext(FormContext);
): ICustomInputValidation<TType> {
const inputRef = useRef<TType | null>(null);
const translate = useTranslate();
@@ -27,37 +27,32 @@ export function useCustomInputValidation<T = void, TType extends HTMLInputElemen
value = element.value as unknown as T;
}
const valid = element.validity.valid;
const result = validation(value);
try {
if (typeof result === 'string') {
element.setCustomValidity(result || translate('core_blocks_custom_input_validation_error'));
return false;
}
element.setCustomValidity('');
return true;
} finally {
if (valid !== element.validity.valid) {
element.reportValidity();
}
if (typeof result === 'string') {
element.setCustomValidity(result || translate('core_blocks_custom_input_validation_error'));
return false;
}
element.setCustomValidity('');
return true;
}
useExecutor({
executor: context?.onValidate,
handlers: [
function validationHandler(_, context) {
if (!inputRef.current) {
return;
}
function revalidateAndReport(element: TType): boolean {
const valid = element.validity.valid;
const result = validate(element);
if (!validate(inputRef.current)) {
ExecutorInterrupter.interrupt(context);
}
},
],
});
if (valid !== element.validity.valid) {
element.reportValidity();
}
return result;
}
function revalidate() {
if (inputRef.current) {
validate(inputRef.current);
}
}
useEffect(() => {
const element = inputRef.current;
@@ -68,14 +63,14 @@ export function useCustomInputValidation<T = void, TType extends HTMLInputElemen
function handleInput(event: Event) {
const target = event.target as TType;
if (target.validity.valid === false) {
validate(target);
revalidateAndReport(target);
}
}
function handleBlur(event: Event) {
const target = event.target as TType;
if (target.validity.valid === true) {
validate(target);
revalidateAndReport(target);
}
}
@@ -88,5 +83,5 @@ export function useCustomInputValidation<T = void, TType extends HTMLInputElemen
};
});
return inputRef;
return { ref: inputRef, revalidate, revalidateAndReport };
}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -0,0 +1,42 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { useContext } from 'react';
import { FormContext, useExecutor, type IFormContext } from '../index.js';
import { useCustomInputValidation, type ICustomInputValidation } from './useCustomInputValidation.js';
import { ExecutorInterrupter } from '@cloudbeaver/core-executor';
export function useFormCustomInputValidation<T = void, TType extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(
validation: (value: T) => string | null,
formContext?: IFormContext,
): ICustomInputValidation<TType> {
const customValidation = useCustomInputValidation<T, TType>(validation);
const reactContext = useContext(FormContext);
const context = formContext ?? reactContext;
if (!context) {
throw new Error('useCustomInputValidation must be used within a FormContext provider');
}
useExecutor({
executor: context?.onValidate,
handlers: [
function validationHandler(_, context) {
if (!customValidation.ref.current) {
return;
}
if (!customValidation.revalidateAndReport(customValidation.ref.current)) {
ExecutorInterrupter.interrupt(context);
}
},
],
});
return customValidation;
}
+1
View File
@@ -179,6 +179,7 @@ export * from './FormControls/Form.js';
export * from './FormControls/Textarea.js';
export * from './FormControls/useCapsLockTracker.js';
export * from './FormControls/useCustomInputValidation.js';
export * from './FormControls/useFormCustomInputValidation.js';
export * from './FormControls/useForm.js';
export * from './FormControls/Textarea.js';
export * from './Link.js';
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -9,21 +9,22 @@ import { PasswordPolicyService } from '@cloudbeaver/core-authentication';
import { useService } from '@cloudbeaver/core-di';
import { PasswordPolicyResource } from '@cloudbeaver/core-root';
import { useCustomInputValidation } from './FormControls/useCustomInputValidation.js';
import { useFormCustomInputValidation } from './FormControls/useFormCustomInputValidation.js';
import { useResource } from './ResourcesHooks/useResource.js';
import type { IFormContext } from './FormControls/FormContext.js';
export function usePasswordValidation() {
export function usePasswordValidation(formContext?: IFormContext) {
useResource(usePasswordValidation, PasswordPolicyResource, undefined);
const passwordPolicyService = useService(PasswordPolicyService);
const ref = useCustomInputValidation<string>(value => {
const { ref } = useFormCustomInputValidation<string>(value => {
if (!value) {
return null;
}
const validation = passwordPolicyService.validatePassword(value);
return validation.isValid ? null : validation.errorMessage;
});
}, formContext);
return ref;
}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@ import {
Textarea,
useResource,
useTranslate,
useCustomInputValidation,
useFormCustomInputValidation,
} from '@cloudbeaver/core-blocks';
import { ServerConfigResource } from '@cloudbeaver/core-root';
@@ -33,7 +33,7 @@ interface Props {
export const ServerConfigurationInfoForm = observer<Props>(function ServerConfigurationInfoForm({ state }) {
const serverConfigLoader = useResource(ServerConfigurationInfoForm, ServerConfigResource, undefined);
const translate = useTranslate();
const validation = useCustomInputValidation<string, HTMLTextAreaElement>(value => {
const { ref: validation } = useFormCustomInputValidation<string, HTMLTextAreaElement>(value => {
const currentHost = window.location.host;
if (!isIp(window.location.hostname) && value.trim() && !value.includes(currentHost)) {
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -8,7 +8,7 @@
import { observer } from 'mobx-react-lite';
import { ADMIN_USERNAME_MIN_LENGTH } from '@cloudbeaver/core-authentication';
import { Group, GroupTitle, InputField, useCustomInputValidation, usePasswordValidation, useTranslate } from '@cloudbeaver/core-blocks';
import { Group, GroupTitle, InputField, useFormCustomInputValidation, usePasswordValidation, useTranslate } from '@cloudbeaver/core-blocks';
import { isValuesEqual } from '@cloudbeaver/core-utils';
import type { IServerConfig } from '@cloudbeaver/plugin-administration';
@@ -20,7 +20,7 @@ export const ServerConfigurationAdminForm = observer<Props>(function ServerConfi
const translate = useTranslate();
const passwordValidationRef = usePasswordValidation();
const passwordRepeatRef = useCustomInputValidation<string>(value => {
const { ref: passwordRepeatRef } = useFormCustomInputValidation<string>(value => {
if (!isValuesEqual(value, serverConfig.adminPassword, null)) {
return translate('authentication_user_passwords_not_match');
}
@@ -1,13 +1,22 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { Container, Group, InputField, Textarea, useAutoLoad, useCustomInputValidation, useResource, useTranslate } from '@cloudbeaver/core-blocks';
import {
Container,
Group,
InputField,
Textarea,
useAutoLoad,
useFormCustomInputValidation,
useResource,
useTranslate,
} from '@cloudbeaver/core-blocks';
import { ServerConfigResource } from '@cloudbeaver/core-root';
import { type TabContainerPanelComponent, useTab, useTabState } from '@cloudbeaver/core-ui';
@@ -27,7 +36,7 @@ export const TeamOptions: TabContainerPanelComponent<TeamFormProps> = observer(f
useAutoLoad(TeamOptions, tabState, tab.selected && !loaded);
const idValidationRef = useCustomInputValidation<string>(value => {
const { ref: idValidationRef } = useFormCustomInputValidation<string>(value => {
const v = value.trim();
if (!v) {
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -12,7 +12,7 @@ import {
Container,
GroupTitle,
InputField,
useCustomInputValidation,
useFormCustomInputValidation,
usePasswordValidation,
useResource,
useTranslate,
@@ -45,7 +45,7 @@ export const UserFormInfoCredentials = observer<Props>(function UserFormInfoCred
local = !editing || (!!userInfo.data && isLocalUser(userInfo.data));
}
const usernameValidationRef = useCustomInputValidation<string>(value => {
const { ref: usernameValidationRef } = useFormCustomInputValidation<string>(value => {
const v = value.trim();
if (!v) {
@@ -59,7 +59,7 @@ export const UserFormInfoCredentials = observer<Props>(function UserFormInfoCred
return null;
});
const passwordRepeatRef = useCustomInputValidation<string>(value => {
const { ref: passwordRepeatRef } = useFormCustomInputValidation<string>(value => {
if (!isValuesEqual(value, tabState.state.password, null)) {
return translate('authentication_user_passwords_not_match');
}
@@ -1,12 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { Select, FieldCheckbox, InputField, Textarea, useCustomInputValidation, useTranslate } from '@cloudbeaver/core-blocks';
import { Select, FieldCheckbox, InputField, Textarea, useTranslate, useFormCustomInputValidation } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import {
ESettingsValueType,
@@ -55,7 +55,7 @@ export const SettingField = observer<Props>(function SettingField({ resolver, se
value = value ?? '';
const customValidation = useCustomInputValidation(value => {
const { ref: customValidation } = useFormCustomInputValidation(value => {
if (!(setting.key in settingsProviderService.schema.shape)) {
return null;
}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -19,9 +19,9 @@ import {
InputField,
ToolsAction,
ToolsPanel,
useCustomInputValidation,
useExecutor,
useForm,
useFormCustomInputValidation,
useObservableRef,
usePasswordValidation,
useTranslate,
@@ -54,13 +54,6 @@ export const ChangePassword = observer(function ChangePassword() {
const userInfoResource = useService(UserInfoResource);
const commonDialogService = useService(CommonDialogService);
const disabled = userInfoResource.isLoading();
const passwordValidationRef = usePasswordValidation();
const passwordRepeatRef = useCustomInputValidation<string>(value => {
if (!isValuesEqual(value, state.password, null)) {
return translate('authentication_user_passwords_not_match');
}
return null;
});
const form = useForm({
async onSubmit() {
@@ -75,6 +68,13 @@ export const ChangePassword = observer(function ChangePassword() {
}
},
});
const passwordValidationRef = usePasswordValidation(form);
const passwordRepeatValidation = useFormCustomInputValidation<string>(value => {
if (!isValuesEqual(value, state.password, null)) {
return translate('authentication_user_passwords_not_match');
}
return null;
}, form);
function resetForm() {
state.oldPassword = '';
@@ -143,11 +143,12 @@ export const ChangePassword = observer(function ChangePassword() {
mapValue={(value?: string) => value?.trim() ?? ''}
small
required
onChange={passwordRepeatValidation.revalidate}
>
{translate('plugin_user_profile_authentication_change_password_new_password')}
</InputField>
<InputField
ref={passwordRepeatRef}
ref={passwordRepeatValidation.ref}
type="password"
name="repeatedPassword"
state={state}