Unify password policy and fix conditional dynamic fields

This commit is contained in:
Lucio Lelii 2026-04-15 12:11:29 +02:00
parent 52333b8601
commit e6f6cda845
7 changed files with 55 additions and 53 deletions

View File

@ -12,7 +12,7 @@ import { AdminCreateUserRequest, UserRole } from '@models/user';
import { Router } from '@angular/router';
import { AdminService } from '@services/admin/admin';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks } from '@utilities/password-validation';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
selector: 'app-admin-create-user-page',
@ -58,7 +58,7 @@ export class AdminCreateUserPage extends FormUtility {
return { kind: 'invalidEmail', message: 'Invalid email address' };
});
required(model.password, { message: 'Password is required' });
minLength(model.password, 8, { message: 'Password must be at least 8 characters long' });
minLength(model.password, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.password, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;

View File

@ -17,7 +17,7 @@ import { AdminService } from '@services/admin/admin';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks } from '@utilities/password-validation';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
selector: 'app-admin-users',
@ -77,7 +77,7 @@ export class AdminUsersPage extends FormUtility {
};
});
required(model.password, { message: 'Password is required' });
minLength(model.password, 8, { message: 'Password must be at least 8 characters long' });
minLength(model.password, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.password, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;

View File

@ -10,7 +10,7 @@ import { environment } from '@environment';
import { UserRegistration } from '@models/user';
import { Authorization } from '@services/authorization/authorization';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity } from '@utilities/password-validation';
import { hasValidPasswordComplexity, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
declare global {
interface Window {
@ -91,7 +91,7 @@ export class Signup extends FormUtility implements AfterViewInit, OnDestroy {
maxLength(model.username, 20, { message: 'Username cannot exceed 20 characters' }),
email(model.email, { message: 'Invalid email address' }),
required(model.email, { message: 'Email is required' }),
minLength(model.password, 8, { message: 'Password must be at least 8 characters long' }),
minLength(model.password, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` }),
validate(model.password, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) {

View File

@ -6,14 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { FormUtility } from '@utilities/form-utility';
function hasValidPasswordComplexity(value: string): boolean {
return /^\S+$/.test(value)
&& /[a-z]/.test(value)
&& /[A-Z]/.test(value)
&& /\d/.test(value)
&& /[^A-Za-z0-9]/.test(value);
}
import { hasValidPasswordComplexity, evaluatePasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
selector: 'app-admin-reset-password-dialog',
@ -40,7 +33,7 @@ export class AdminResetPasswordDialogComponent extends FormUtility {
readonly passwordForm = form(this.model, (model) => {
required(model.newPassword, { message: 'New password is required' });
minLength(model.newPassword, 8, { message: 'Password must be at least 8 characters long' });
minLength(model.newPassword, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.newPassword, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
@ -63,17 +56,7 @@ export class AdminResetPasswordDialogComponent extends FormUtility {
});
readonly canSubmit = computed(() => !this.passwordForm().invalid() && !this.saving());
readonly newPasswordChecks = computed(() => {
const password = this.model().newPassword;
return [
{ label: 'At least 8 characters', satisfied: password.length >= 8 },
{ label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) },
{ label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) },
{ label: 'At least one number', satisfied: /\d/.test(password) },
{ label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) },
{ label: 'No spaces', satisfied: /^\S*$/.test(password) }
];
});
readonly newPasswordChecks = computed(() => evaluatePasswordChecks(this.model().newPassword));
close(event?: Event) {
event?.preventDefault();

View File

@ -7,14 +7,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ChangePasswordRequest } from '@models/user';
import { FormUtility } from '@utilities/form-utility';
function hasValidPasswordComplexity(value: string): boolean {
return /^\S+$/.test(value)
&& /[a-z]/.test(value)
&& /[A-Z]/.test(value)
&& /\d/.test(value)
&& /[^A-Za-z0-9]/.test(value);
}
import { hasValidPasswordComplexity, evaluatePasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
selector: 'app-change-password-dialog',
@ -45,7 +38,7 @@ export class ChangePasswordDialogComponent extends FormUtility {
required(model.oldPassword, { message: 'Current password is required' });
required(model.newPassword, { message: 'New password is required' });
minLength(model.newPassword, 8, { message: 'Password must be at least 8 characters long' });
minLength(model.newPassword, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.newPassword, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
@ -68,17 +61,7 @@ export class ChangePasswordDialogComponent extends FormUtility {
});
readonly canSubmit = computed(() => !this.passwordForm().invalid() && !this.saving());
readonly newPasswordChecks = computed(() => {
const password = this.model().newPassword;
return [
{ label: 'At least 8 characters', satisfied: password.length >= 8 },
{ label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) },
{ label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) },
{ label: 'At least one number', satisfied: /\d/.test(password) },
{ label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) },
{ label: 'No spaces', satisfied: /^\S*$/.test(password) }
];
});
readonly newPasswordChecks = computed(() => evaluatePasswordChecks(this.model().newPassword));
close(event?: Event) {
event?.preventDefault();

View File

@ -1391,6 +1391,12 @@ export class GenericNodeComponent {
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (shouldSkipSchemaField(key, propertySchema)) continue;
const fieldUi = this.toFieldUiMeta(propertySchema);
const visible = fieldUi.visibleWhen.every((rule) =>
evaluateUiConditionRule(rule, item, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath))
);
if (!visible) continue;
if (this.hasDynamicSchema(propertySchema)) {
const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item);
fields.push(...dynamicFields.fields);
@ -1415,7 +1421,10 @@ export class GenericNodeComponent {
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined,
tip: schemaFieldDescription(propertySchema) ?? undefined
tip: schemaFieldDescription(propertySchema) ?? undefined,
readonly: !fieldUi.enabledWhen.every((rule) =>
evaluateUiConditionRule(rule, item, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath))
)
});
if (fieldType === 'checkbox') {
@ -1464,6 +1473,13 @@ export class GenericNodeComponent {
for (const [key, rawPropertySchema] of Object.entries(properties)) {
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (shouldSkipSchemaField(key, propertySchema)) continue;
const fieldUi = this.toFieldUiMeta(propertySchema);
const visible = fieldUi.visibleWhen.every((rule) =>
evaluateUiConditionRule(rule, result, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath))
);
if (!visible) continue;
if (this.hasDynamicSchema(propertySchema)) {
const dynamicValue = this.extractNestedDialogValues(result, key);
nextItem[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousItem[key] ?? {});
@ -1608,7 +1624,8 @@ export class GenericNodeComponent {
const walk = async (
node: Record<string, any>,
pathPrefix: string,
titlePrefix: string
titlePrefix: string,
inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null }
) => {
const resolved = resolveSchemaRef(node, schema);
const properties = resolved?.['properties'] as Record<string, any> | undefined;
@ -1617,13 +1634,27 @@ export class GenericNodeComponent {
for (const [childKey, rawChildSchema] of Object.entries(properties)) {
const childSchema = resolveSchemaRef(rawChildSchema as Record<string, any>, schema);
if (shouldSkipSchemaField(childKey, childSchema)) continue;
const childUi = this.toFieldUiMeta(childSchema, inheritedUi);
const fieldRelativePath = pathPrefix === keyPrefix
? childKey
: `${pathPrefix.slice(`${keyPrefix}.`.length)}.${childKey}`;
const visible = childUi.visibleWhen.every((rule) =>
evaluateUiConditionRule(rule, currentRecord, (fieldPath) => resolveSchemaPath(schema, fieldPath))
);
if (!visible) continue;
const nextPath = `${pathPrefix}.${childKey}`;
const nextLabel = `${titlePrefix} ${schemaFieldLabel(childKey, childSchema)}`;
const currentNestedValue = getValueByPath(currentRecord, nextPath.slice(`${keyPrefix}.`.length));
const currentNestedValue = getValueByPath(currentRecord, fieldRelativePath);
const hasChildren = !!childSchema?.['properties'] || childSchema?.type === 'object';
if (hasChildren) {
await walk(childSchema as Record<string, any>, nextPath, nextLabel);
await walk(childSchema as Record<string, any>, nextPath, nextLabel, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
});
continue;
}
@ -1646,7 +1677,10 @@ export class GenericNodeComponent {
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof childSchema?.['x-ui-placeholder'] === 'string' ? String(childSchema['x-ui-placeholder']) : undefined,
tip: schemaFieldDescription(childSchema) ?? undefined
tip: schemaFieldDescription(childSchema) ?? undefined,
readonly: !childUi.enabledWhen.every((rule) =>
evaluateUiConditionRule(rule, currentRecord, (fieldPath) => resolveSchemaPath(schema, fieldPath))
)
});
if (fieldType === 'checkbox') {

View File

@ -1,3 +1,5 @@
export const PASSWORD_MIN_LENGTH = 10;
export function hasValidPasswordComplexity(value: string): boolean {
return /^\S+$/.test(value)
&& /[a-z]/.test(value)
@ -10,7 +12,7 @@ export type PasswordCheck = { label: string; satisfied: boolean };
export function evaluatePasswordChecks(password: string): PasswordCheck[] {
return [
{ label: 'At least 8 characters', satisfied: password.length >= 8 },
{ label: `At least ${PASSWORD_MIN_LENGTH} characters`, satisfied: password.length >= PASSWORD_MIN_LENGTH },
{ label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) },
{ label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) },
{ label: 'At least one number', satisfied: /\d/.test(password) },
@ -21,7 +23,7 @@ export function evaluatePasswordChecks(password: string): PasswordCheck[] {
export function initialPasswordChecks(): PasswordCheck[] {
return [
{ label: 'At least 8 characters', satisfied: false },
{ label: `At least ${PASSWORD_MIN_LENGTH} characters`, satisfied: false },
{ label: 'At least one lowercase letter', satisfied: false },
{ label: 'At least one uppercase letter', satisfied: false },
{ label: 'At least one number', satisfied: false },