AI Implementation feature(891): Admin Area General Settings and Categories 1.09 (#31)
This commit was merged in pull request #31.
This commit is contained in:
@@ -8,11 +8,14 @@ import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { AdminCategoriesComponent } from './categories/categories.component';
|
||||
import {
|
||||
datetimeMessage,
|
||||
defaultChallengeAddressMessage,
|
||||
deriveEventState,
|
||||
endAfterStartValidator,
|
||||
endBeforeStartMessage,
|
||||
eventEndFieldMessage,
|
||||
isValidDefaultChallengeAddress,
|
||||
isoDatetimeValidator,
|
||||
normalizeDefaultChallengeAddress,
|
||||
normalizePageTitle,
|
||||
pageTitleMessage,
|
||||
toDatetimeLocal,
|
||||
@@ -126,7 +129,19 @@ import {
|
||||
|
||||
<label for="defaultChallengeIp">Default challenge IP</label>
|
||||
<div>
|
||||
<input id="defaultChallengeIp" type="text" formControlName="defaultChallengeIp" data-testid="general-defaultIp" />
|
||||
<input
|
||||
id="defaultChallengeIp"
|
||||
type="text"
|
||||
formControlName="defaultChallengeIp"
|
||||
data-testid="general-defaultIp"
|
||||
[attr.aria-invalid]="showDefaultChallengeIpError() ? 'true' : null"
|
||||
[attr.aria-describedby]="showDefaultChallengeIpError() ? 'general-defaultIp-error' : null"
|
||||
/>
|
||||
@if (showDefaultChallengeIpError()) {
|
||||
<div class="field-error" id="general-defaultIp-error" data-testid="general-defaultIp-error">
|
||||
{{ defaultChallengeIpMessage() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label for="registrationsEnabled">Enable registrations</label>
|
||||
@@ -209,6 +224,10 @@ export class AdminGeneralComponent implements OnInit {
|
||||
private readonly eventEndInvalid = signal<boolean>(false);
|
||||
private readonly eventEndTouchedOrDirty = signal<boolean>(false);
|
||||
|
||||
private readonly defaultChallengeIpValue = signal<string>('');
|
||||
private readonly defaultChallengeIpInvalid = signal<boolean>(false);
|
||||
private readonly defaultChallengeIpTouchedOrDirty = signal<boolean>(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
|
||||
@@ -217,7 +236,7 @@ export class AdminGeneralComponent implements OnInit {
|
||||
themeKey: this.fb.nonNullable.control('classic'),
|
||||
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
|
||||
eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
|
||||
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]),
|
||||
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required, this.defaultChallengeAddressValidator()]),
|
||||
registrationsEnabled: this.fb.nonNullable.control(false),
|
||||
},
|
||||
{ validators: endAfterStartValidator },
|
||||
@@ -262,6 +281,17 @@ export class AdminGeneralComponent implements OnInit {
|
||||
return eventEndFieldMessage(fieldErrs, crossErrs);
|
||||
});
|
||||
|
||||
readonly showDefaultChallengeIpError = computed(
|
||||
() => this.defaultChallengeIpTouchedOrDirty() && this.defaultChallengeIpInvalid(),
|
||||
);
|
||||
|
||||
readonly defaultChallengeIpMessage = computed(() =>
|
||||
defaultChallengeAddressMessage(
|
||||
this.defaultChallengeIpValue(),
|
||||
this.form.controls.defaultChallengeIp.errors,
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.form.controls.welcomeMarkdown.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -317,6 +347,23 @@ export class AdminGeneralComponent implements OnInit {
|
||||
this.eventEndInvalid.set(ee.invalid);
|
||||
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
|
||||
});
|
||||
|
||||
const ip = this.form.controls.defaultChallengeIp;
|
||||
this.defaultChallengeIpValue.set(ip.value);
|
||||
this.defaultChallengeIpInvalid.set(ip.invalid);
|
||||
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
|
||||
ip.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((v) => {
|
||||
this.defaultChallengeIpValue.set(v);
|
||||
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
|
||||
});
|
||||
ip.statusChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
this.defaultChallengeIpInvalid.set(ip.invalid);
|
||||
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -344,6 +391,19 @@ export class AdminGeneralComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
private defaultChallengeAddressValidator(): (ctrl: { value: string | null }) => { defaultChallengeAddressFormat: true } | null {
|
||||
return (ctrl) => {
|
||||
const value = ctrl?.value ?? '';
|
||||
if (value.length === 0) return null;
|
||||
if (value.trim().length === 0) return null;
|
||||
if (value.length > 255) return { defaultChallengeAddressFormat: true };
|
||||
if (!isValidDefaultChallengeAddress(value)) {
|
||||
return { defaultChallengeAddressFormat: true };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
private applySettings(s: GeneralSettings): void {
|
||||
this.form.patchValue({
|
||||
pageTitle: s.pageTitle,
|
||||
@@ -366,6 +426,10 @@ export class AdminGeneralComponent implements OnInit {
|
||||
this.form.controls.eventEndUtc.markAsUntouched();
|
||||
this.form.controls.eventEndUtc.markAsPristine();
|
||||
this.eventEndTouchedOrDirty.set(false);
|
||||
|
||||
this.form.controls.defaultChallengeIp.markAsUntouched();
|
||||
this.form.controls.defaultChallengeIp.markAsPristine();
|
||||
this.defaultChallengeIpTouchedOrDirty.set(false);
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
@@ -385,7 +449,7 @@ export class AdminGeneralComponent implements OnInit {
|
||||
themeKey: v.themeKey,
|
||||
eventStartUtc: toIsoUtc(v.eventStartUtc),
|
||||
eventEndUtc: toIsoUtc(v.eventEndUtc),
|
||||
defaultChallengeIp: v.defaultChallengeIp,
|
||||
defaultChallengeIp: normalizeDefaultChallengeAddress(v.defaultChallengeIp),
|
||||
registrationsEnabled: v.registrationsEnabled,
|
||||
});
|
||||
this.applySettings(updated);
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
export type DefaultChallengeAddressError = 'required' | 'format' | null;
|
||||
|
||||
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
|
||||
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
|
||||
|
||||
function isValidIpv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (part.length === 0 || part.length > 3) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
if (!/^[0-9]+$/.test(part)) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidHostname(value: string): boolean {
|
||||
if (value.length === 0 || value.length > 253) return false;
|
||||
if (value.startsWith('.') || value.endsWith('.')) return false;
|
||||
const labels = value.split('.');
|
||||
if (labels.length < 2) return false;
|
||||
if (labels.some((label) => ALL_NUMERIC_LABEL.test(label))) return false;
|
||||
return labels.every((label) => HOSTNAME_LABEL.test(label));
|
||||
}
|
||||
|
||||
export function isValidDefaultChallengeAddress(value: string): boolean {
|
||||
if (isValidIpv4(value)) return true;
|
||||
return isValidHostname(value);
|
||||
}
|
||||
|
||||
export function defaultChallengeAddressError(
|
||||
value: string | null | undefined,
|
||||
maxLength = 255,
|
||||
): DefaultChallengeAddressError {
|
||||
const raw = value ?? '';
|
||||
if (raw.trim().length === 0) return 'required';
|
||||
if (raw.length > maxLength) return 'format';
|
||||
if (!isValidDefaultChallengeAddress(raw)) return 'format';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeDefaultChallengeAddress(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export const defaultChallengeAddressRequiredMessage =
|
||||
'Default challenge IP is required and cannot contain only whitespace.';
|
||||
|
||||
export function defaultChallengeAddressMessage(
|
||||
value: string | null | undefined,
|
||||
controlErrors: { [key: string]: unknown } | null | undefined,
|
||||
maxLength = 255,
|
||||
): string | null {
|
||||
const err = defaultChallengeAddressError(value, maxLength);
|
||||
if (err === 'required') return defaultChallengeAddressRequiredMessage;
|
||||
if (err === 'format') return 'Default challenge IP must be a valid IPv4 address or hostname.';
|
||||
if (controlErrors?.['required']) return defaultChallengeAddressRequiredMessage;
|
||||
if (controlErrors?.['defaultChallengeAddressFormat']) {
|
||||
return 'Default challenge IP must be a valid IPv4 address or hostname.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type PageTitleError = 'required' | 'maxlength' | null;
|
||||
|
||||
export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError {
|
||||
@@ -108,4 +173,4 @@ export function eventEndFieldMessage(
|
||||
return endBeforeStartMessage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user