AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-07-21 22:26:47 +00:00
parent 8dc8cee769
commit b6dbfcc511
46 changed files with 2705 additions and 211 deletions
@@ -0,0 +1,163 @@
import {
ChangeDetectionStrategy,
Component,
computed,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import {
passwordMatchValidator,
PasswordPolicy,
} from './change-password-modal.validators';
export type ChangePasswordMode = 'self' | 'admin-reset';
export interface ChangePasswordSubmitPayload {
oldPassword: string;
newPassword: string;
confirmNewPassword: string;
}
const DEFAULT_POLICY: PasswordPolicy = {
minLength: 12,
requireMixed: true,
description: 'At least 12 characters with upper, lower, digit and symbol',
};
@Component({
selector: 'app-change-password-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="cp-title"
data-testid="change-password-modal"
(click)="$event.stopPropagation()"
>
<h2 id="cp-title">Change password</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (mode() === 'self') {
<label class="field">
<span>Old password</span>
<input
type="password"
autocomplete="current-password"
formControlName="oldPassword"
data-testid="cp-old"
/>
</label>
}
<label class="field">
<span>New password</span>
<input
type="password"
autocomplete="new-password"
formControlName="newPassword"
data-testid="cp-new"
/>
</label>
<label class="field">
<span>Confirm new password</span>
<input
type="password"
autocomplete="new-password"
formControlName="confirmNewPassword"
data-testid="cp-confirm"
/>
</label>
@if (errorMessage()) {
<p class="error" data-testid="cp-error">{{ errorMessage() }}</p>
}
@if (formError()) {
<p class="error" data-testid="cp-form-error">{{ formError() }}</p>
}
<p class="hint" data-testid="cp-policy">{{ policy().description }}</p>
<div class="actions">
<button
type="button"
data-testid="cp-cancel"
(click)="onCancel()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
data-testid="cp-ok"
[disabled]="submitting()"
>
OK
</button>
</div>
</form>
</div>
</div>
}
`,
})
export class ChangePasswordModalComponent {
private readonly fb = inject(FormBuilder);
readonly open = input<boolean>(false);
readonly mode = input<ChangePasswordMode>('self');
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
readonly errorMessage = input<string | null>(null);
readonly submitting = input<boolean>(false);
readonly cancel = output<void>();
readonly submit = output<ChangePasswordSubmitPayload>();
readonly form = this.fb.nonNullable.group(
{
oldPassword: this.fb.nonNullable.control(''),
newPassword: this.fb.nonNullable.control('', [Validators.required]),
confirmNewPassword: this.fb.nonNullable.control('', [Validators.required]),
},
{ validators: passwordMatchValidator },
);
readonly formError = computed<string | null>(() => {
const errs = this.form.errors;
if (errs?.['passwordMismatch']) return 'Passwords do not match';
return null;
});
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
}
onCancel(): void {
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
this.cancel.emit();
}
onSubmit(): void {
if (this.submitting()) return;
const v = this.form.getRawValue();
if (this.mode() === 'self' && !v.oldPassword) return;
if (!v.newPassword || !v.confirmNewPassword) return;
if (this.form.invalid) return;
this.submit.emit({
oldPassword: v.oldPassword,
newPassword: v.newPassword,
confirmNewPassword: v.confirmNewPassword,
});
}
}
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const PasswordPolicySchema = z.object({
minLength: z.number().int().nonnegative(),
requireMixed: z.boolean(),
description: z.string(),
});
export type PasswordPolicy = z.infer<typeof PasswordPolicySchema>;
export function passwordMatchValidator(group: { get: (k: string) => any; errors?: any }): { passwordMismatch?: boolean } | null {
const newP = group.get('newPassword')?.value;
const conf = group.get('confirmNewPassword')?.value;
if (!newP || !conf) return null;
return newP === conf ? null : { passwordMismatch: true };
}
export function buildPasswordPolicyMessage(policy: PasswordPolicy): string {
return policy.description;
}
@@ -0,0 +1,19 @@
export function formatChangePasswordError(input: {
code: string | null | undefined;
message?: string | null;
}): string {
const code = (input.code ?? '').toString();
const message = (input.message ?? '').toString();
switch (code) {
case 'INVALID_OLD_PASSWORD':
return 'Old password is incorrect';
case 'PASSWORDS_DO_NOT_MATCH':
return 'New password and confirmation do not match';
case 'PASSWORD_POLICY':
return message || 'New password does not meet the policy requirements';
case 'UNAUTHORIZED':
return 'You must be signed in to change your password';
default:
return message || 'Failed to change password';
}
}
@@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventState } from '../../../core/services/event-status.store';
@Component({
selector: 'app-shell-header',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<header class="shell-header" data-testid="shell-header">
<button
type="button"
class="shell-title"
data-testid="shell-title"
(click)="titleClick.emit()"
>
{{ pageTitle() }}
</button>
<div class="shell-section" data-testid="shell-active-section">
{{ activeSection() }}
</div>
<div class="shell-right">
<span
class="led"
[class.led-running]="eventState() === 'running'"
[class.led-countdown]="eventState() === 'countdown'"
[class.led-stopped]="eventState() === 'stopped'"
[class.led-unconfigured]="eventState() === 'unconfigured'"
data-testid="shell-led"
[attr.aria-label]="'Event status: ' + eventState()"
></span>
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
<div class="user-menu-wrapper">
<button
type="button"
class="user-menu-trigger"
data-testid="user-menu-trigger"
(click)="usernameMenuToggle.emit()"
>
{{ username() }} <span aria-hidden="true">▾</span>
</button>
@if (userMenuOpen()) {
<div class="user-menu" role="menu" data-testid="user-menu">
<button
type="button"
class="user-menu-item"
data-testid="user-menu-rank"
(click)="rankClick.emit()"
>
{{ rankText() }}
</button>
<button
type="button"
class="user-menu-item"
data-testid="user-menu-change-password"
(click)="changePasswordClick.emit()"
>
Change password
</button>
@if (canAccessAdmin()) {
<button
type="button"
class="user-menu-item"
data-testid="user-menu-admin"
(click)="adminClick.emit()"
>
Admin area
</button>
}
<button
type="button"
class="user-menu-item"
data-testid="user-menu-logout"
(click)="logoutClick.emit()"
>
Logout
</button>
</div>
}
</div>
</div>
</header>
`,
})
export class ShellHeaderComponent {
readonly pageTitle = input<string>('HIPCTF');
readonly activeSection = input<string>('Challenges');
readonly eventState = input<EventState>('unconfigured');
readonly countdownText = input<string>('');
readonly username = input<string>('');
readonly rankText = input<string>('');
readonly canAccessAdmin = input<boolean>(false);
readonly userMenuOpen = input<boolean>(false);
readonly titleClick = output<void>();
readonly usernameMenuToggle = output<void>();
readonly rankClick = output<void>();
readonly changePasswordClick = output<void>();
readonly adminClick = output<void>();
readonly logoutClick = output<void>();
readonly ledClass = computed(() => `led led-${this.eventState()}`);
}
@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface ShellTab {
id: string;
label: string;
}
@Component({
selector: 'app-quick-tabs',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<nav class="quick-tabs" data-testid="quick-tabs">
@for (tab of tabs(); track tab.id) {
<button
type="button"
class="quick-tab"
[class.active]="active() === tab.id"
[attr.data-testid]="'quick-tab-' + tab.id"
(click)="onTabClick(tab)"
>
{{ tab.label }}
</button>
}
</nav>
`,
})
export class QuickTabsComponent {
readonly tabs = input.required<ShellTab[]>();
readonly active = input.required<string>();
readonly tabChange = output<{ id: string }>();
onTabClick(tab: ShellTab): void {
this.tabChange.emit({ id: tab.id });
}
}