AI Implementation feature(821): First-Start Create Admin Modal (#2)

This commit was merged in pull request #2.
This commit is contained in:
2026-07-21 14:45:55 +00:00
parent 03bcb6b156
commit 9f67ec8c50
30 changed files with 1306 additions and 141 deletions
@@ -0,0 +1,119 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
import { passwordMatchValidator } from './setup-create-admin.validators';
@Component({
selector: 'app-setup-create-admin',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
templateUrl: './setup-create-admin.component.html',
styleUrls: ['./setup-create-admin.component.css'],
})
export class SetupCreateAdminComponent {
private readonly fb = inject(FormBuilder);
private readonly api = inject(SetupCreateAdminService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly bootstrap = inject(BootstrapService);
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
readonly form = this.fb.nonNullable.group(
{
username: this.fb.nonNullable.control('', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(32),
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
]),
password: this.fb.nonNullable.control('', [Validators.required]),
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
},
{ validators: passwordMatchValidator },
);
readonly submitting = signal(false);
readonly serverError = signal<string | null>(null);
readonly serverErrorCode = signal<string | null>(null);
readonly canRetry = computed(
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
);
readonly usernameErrors = computed(() => {
const c = this.form.controls.username;
if (!c.touched || c.valid) return null;
if (c.errors?.['required']) return 'Username is required';
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
return 'Invalid username';
});
readonly confirmErrors = computed(() => {
const c = this.form.controls.passwordConfirm;
const groupMismatch = this.form.errors?.['passwordMismatch'];
if ((!c.touched || c.valid) && !groupMismatch) return null;
if (c.errors?.['required']) return 'Please confirm your password';
if (groupMismatch) return 'Passwords do not match';
return 'Invalid value';
});
@HostListener('document:keydown.escape', ['$event'])
swallowEscape(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
}
swallowBackdrop(_event: MouseEvent): void {
// intentionally no-op: modal cannot be dismissed by clicking the backdrop
}
async submit(): Promise<void> {
if (this.submitting()) return;
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.serverError.set(null);
this.serverErrorCode.set(null);
const { username, password, passwordConfirm } = this.form.getRawValue();
const result = await this.api.create({ username, password, passwordConfirm });
this.submitting.set(false);
if (result.ok) {
this.auth.setSession(result.accessToken, result.user);
this.bootstrap.markInitialized();
await this.router.navigateByUrl('/challenges');
return;
}
const failure = result as CreateAdminFailure;
if (failure.code === 'USERNAME_TAKEN') {
this.serverError.set('Username already exists');
this.serverErrorCode.set('USERNAME_TAKEN');
return;
}
this.serverError.set('Setup failed, please retry');
this.serverErrorCode.set(failure.code);
}
}