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:
2026-07-22 15:47:04 +00:00
parent 2a6182b938
commit 4656a5b082
11 changed files with 432 additions and 42 deletions
@@ -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;
}
}