From 7f37b647756e330cbfacba671d2c9c474411e499 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Wed, 5 Aug 2026 23:28:16 -0700 Subject: [PATCH 1/7] refactor: remove leaders and convert to radio group --- frontend/src/app/pages/_shared-auth.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/app/pages/_shared-auth.scss b/frontend/src/app/pages/_shared-auth.scss index 914ed33..a3ec6d2 100644 --- a/frontend/src/app/pages/_shared-auth.scss +++ b/frontend/src/app/pages/_shared-auth.scss @@ -33,3 +33,22 @@ min-height: 1.25rem; color: #9c0754; } + +.radio-group { + display: flex; + gap: 1.5rem; + width: 100%; +} + +.radio-option { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 0.375rem; + border: 1px solid #808080; +} + +.radio-option:has(:checked) input { + accent-color: #9c0754; +} From 7231ec1ab5fd2cedb022f6d66fa770762829b0b4 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 8 Aug 2026 15:19:03 -0700 Subject: [PATCH 2/7] test: add unit test for hidden name and password based off user type --- .../register-page.component.html | 11 ++++++++-- .../register-page.component.spec.ts | 22 +++++++++++++++++++ .../register-page/register-page.component.ts | 3 +++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/register-page/register-page.component.html b/frontend/src/app/pages/register-page/register-page.component.html index effe789..005d8b4 100644 --- a/frontend/src/app/pages/register-page/register-page.component.html +++ b/frontend/src/app/pages/register-page/register-page.component.html @@ -9,8 +9,15 @@

Register

- - + @if (!registrationForm.name().hidden()) { + + + } @if (!registrationForm.adminPassword().hidden()) { diff --git a/frontend/src/app/pages/register-page/register-page.component.spec.ts b/frontend/src/app/pages/register-page/register-page.component.spec.ts index c8efaea..ced4829 100644 --- a/frontend/src/app/pages/register-page/register-page.component.spec.ts +++ b/frontend/src/app/pages/register-page/register-page.component.spec.ts @@ -87,6 +87,23 @@ describe('RegisterPageComponent', () => { expect(api.registerAdmin).not.toHaveBeenCalled(); expect(api.registerPlayer).not.toHaveBeenCalled(); }); + + it('shows name field for Player and password field for Admin', () => { + const component = TestBed.createComponent(RegisterPageComponent) + .componentInstance as unknown as RegisterPageHarness; + component.registrationModel.set({ + registrationKind: RegistrationKind.Player, + name: 'Test', + adminPassword: '', + }); + + expect(component.registrationForm.name().hidden()).toBe(false); + expect(component.registrationForm.adminPassword().hidden()).toBe(true); + + component.registrationForm.registrationKind().value.set(RegistrationKind.Admin); + expect(component.registrationForm.name().hidden()).toBe(true); + expect(component.registrationForm.adminPassword().hidden()).toBe(false); + }); }); interface RegisterPageHarness { @@ -95,6 +112,11 @@ interface RegisterPageHarness { name: string; adminPassword: string; }>; + registrationForm: { + name(): { hidden(): boolean }; + adminPassword(): { hidden(): boolean }; + registrationKind(): { value: WritableSignal }; + }; submit(event: SubmitEvent): Promise; } diff --git a/frontend/src/app/pages/register-page/register-page.component.ts b/frontend/src/app/pages/register-page/register-page.component.ts index 12c8c18..96b7474 100644 --- a/frontend/src/app/pages/register-page/register-page.component.ts +++ b/frontend/src/app/pages/register-page/register-page.component.ts @@ -44,6 +44,9 @@ export class RegisterPageComponent { protected readonly registrationForm = form(this.registrationModel, (registration) => { required(registration.registrationKind); required(registration.name, { message: 'Enter your name.' }); + hidden(registration.name, { + when: ({ valueOf }) => valueOf(registration.registrationKind) === RegistrationKind.Admin, + }); pattern(registration.name, /\S/, { message: 'Enter your name.' }); maxLength(registration.name, 80, { message: 'Your name must be 80 characters or fewer.' }); hidden(registration.adminPassword, { From 9e81f249ab1696f868591a1fb8d068ea3b2d1082 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 8 Aug 2026 15:43:13 -0700 Subject: [PATCH 3/7] feat: remove name requirement from admin registration --- api/admin.go | 12 +----------- api/admin_test.go | 8 +++----- frontend/src/app/core/api.service.spec.ts | 4 ++-- frontend/src/app/core/api.service.ts | 8 ++------ 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/api/admin.go b/api/admin.go index d6fd081..1834bf4 100644 --- a/api/admin.go +++ b/api/admin.go @@ -15,7 +15,6 @@ import ( const adminCookieName = "pacmacro_admin" type AdminRegistrationRequest struct { - Name string `json:"name"` Pass string `json:"pass"` } @@ -35,7 +34,6 @@ type Admin struct { password string cookieValue string - name string registered bool stateMutex sync.RWMutex socketMutex sync.Mutex @@ -46,7 +44,6 @@ func (a *Admin) Init(players *Players, sockets *Sockets, password string, games a.sockets = sockets a.password = password a.cookieValue = base64.RawURLEncoding.EncodeToString([]byte(password)) - a.name = "" a.registered = false a.connections = make(map[adminSocketConnection]struct{}) if len(games) > 0 { @@ -160,7 +157,6 @@ func (a *Admin) ServeReset(w http.ResponseWriter, r *http.Request) { } // POST /api/admin/register -// JSON "name": administrator display name // JSON "pass": administrator password from ADMIN_PASSWORD func (a *Admin) ServeRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -173,18 +169,12 @@ func (a *Admin) ServeRegister(w http.ResponseWriter, r *http.Request) { return } - name := strings.TrimSpace(request.Name) - if name == "" { - writeJSONError(w, http.StatusBadRequest) - return - } if !credentialsMatch(request.Pass, a.password) { writeJSONError(w, http.StatusUnauthorized) return } a.stateMutex.Lock() - a.name = name a.registered = true a.stateMutex.Unlock() @@ -198,7 +188,7 @@ func (a *Admin) ServeRegister(w http.ResponseWriter, r *http.Request) { }) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusNoContent) - fmt.Printf("Admin\tServeRegister (/api/admin/register):\tRegistered administrator %q.\n", name) + fmt.Print("Admin\tServeRegister (/api/admin/register):\tRegistered administrator.\n") } // POST /api/admin/update/ diff --git a/api/admin_test.go b/api/admin_test.go index b94e285..e6bc556 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -const testUser = "test" - type recordingAdminConnection struct { messages [][]byte closed bool @@ -41,7 +39,7 @@ func registerTestAdmin(t *testing.T, admin *Admin, password string) *http.Cookie t, http.MethodPost, "/api/admin/register", - AdminRegistrationRequest{Name: testUser, Pass: password}, + AdminRegistrationRequest{Pass: password}, ) request.Header.Set("X-Forwarded-Proto", "https") response := httptest.NewRecorder() @@ -87,7 +85,7 @@ func TestAdminRegistrationRejectsWrongPasswordAndAllowsSessionRenewal(t *testing t, http.MethodPost, "/api/admin/register", - AdminRegistrationRequest{Name: testUser, Pass: "wrong"}, + AdminRegistrationRequest{Pass: "wrong"}, ) wrongResponse := httptest.NewRecorder() admin.ServeHTTP(wrongResponse, wrongRequest) @@ -100,7 +98,7 @@ func TestAdminRegistrationRejectsWrongPasswordAndAllowsSessionRenewal(t *testing t, http.MethodPost, "/api/admin/register", - AdminRegistrationRequest{Name: "Grace", Pass: "top-secret"}, + AdminRegistrationRequest{Pass: "top-secret"}, ) renewalResponse := httptest.NewRecorder() admin.ServeHTTP(renewalResponse, renewalRequest) diff --git a/frontend/src/app/core/api.service.spec.ts b/frontend/src/app/core/api.service.spec.ts index ee01e75..c6a6c63 100644 --- a/frontend/src/app/core/api.service.spec.ts +++ b/frontend/src/app/core/api.service.spec.ts @@ -40,11 +40,11 @@ describe('ApiService', () => { }); it('registers the admin separately and requests cookie credentials', () => { - api.registerAdmin('Test2', 'top-secret').subscribe(); + api.registerAdmin('top-secret').subscribe(); const request = http.expectOne('/api/admin/register'); expect(request.request.method).toBe('POST'); expect(request.request.withCredentials).toBe(true); - expect(request.request.body).toEqual({ name: 'Test2', pass: 'top-secret' }); + expect(request.request.body).toEqual({ pass: 'top-secret' }); expect(request.request.detectContentTypeHeader()).toBe('application/json'); request.flush(null, { status: 204, statusText: 'No Content' }); }); diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index c268ab8..d7980a7 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -24,12 +24,8 @@ export class ApiService { }); } - registerAdmin(name: string, password: string): Observable { - return this.http.post( - '/api/admin/register', - { name, pass: password }, - { withCredentials: true }, - ); + registerAdmin(password: string): Observable { + return this.http.post('/api/admin/register', { pass: password }, { withCredentials: true }); } getPlayers(): Observable { From f6d847f8e593c5a46cee1f210513ff9ec49b5e1b Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sun, 9 Aug 2026 15:56:25 -0700 Subject: [PATCH 4/7] feat: remove admin from register page --- .../register-page.component.html | 29 ++-------- .../register-page.component.spec.ts | 58 +------------------ .../register-page/register-page.component.ts | 19 +----- 3 files changed, 7 insertions(+), 99 deletions(-) diff --git a/frontend/src/app/pages/register-page/register-page.component.html b/frontend/src/app/pages/register-page/register-page.component.html index 005d8b4..bc7c39e 100644 --- a/frontend/src/app/pages/register-page/register-page.component.html +++ b/frontend/src/app/pages/register-page/register-page.component.html @@ -2,32 +2,11 @@
-

Register

- - +

Register as Player

- @if (!registrationForm.name().hidden()) { - - - } - - @if (!registrationForm.adminPassword().hidden()) { - - - } +
+ + - - - +
+ + -
- @for (player of players(); track player.id) { -
-
- {{ player.name }} - {{ player.id }} - - {{ isConnected(player) ? 'Connected' : 'Offline' }} - -
+ +

{{ status() }}

+ +
+} @else { +
+
+

PacMacro control panel

+

Admin

+
-
- @for (playerType of playerTypes; track playerType.value) { - - - } -
- - } @empty { -

Players will appear here when they register.

- } - -
+

Websocket: {{ connectionStatus() }}

+

{{ status() }}

+ +
+ Open Map + + + +
+ +
+ @for (player of players(); track player.id) { +
+
+ {{ player.name }} + {{ player.id }} + + {{ isConnected(player) ? 'Connected' : 'Offline' }} + +
+ +
+ @for (playerType of playerTypes; track playerType.value) { + + + } +
+
+ } @empty { +

Players will appear here when they register.

+ } +
+ +} diff --git a/frontend/src/app/pages/admin-page/admin-page.component.scss b/frontend/src/app/pages/admin-page/admin-page.component.scss index 7a12aee..5af9fb4 100644 --- a/frontend/src/app/pages/admin-page/admin-page.component.scss +++ b/frontend/src/app/pages/admin-page/admin-page.component.scss @@ -1,5 +1,10 @@ +@use '../shared-auth'; + :host { display: block; +} + +:host.admin-authenticated { min-height: 100dvh; background: #f5f5f7; color: #111; diff --git a/frontend/src/app/pages/admin-page/admin-page.component.spec.ts b/frontend/src/app/pages/admin-page/admin-page.component.spec.ts index c1b2ec7..adfd24b 100644 --- a/frontend/src/app/pages/admin-page/admin-page.component.spec.ts +++ b/frontend/src/app/pages/admin-page/admin-page.component.spec.ts @@ -1,4 +1,5 @@ -import { signal } from '@angular/core'; +playerTypefrontend/src/app/pages/admin-page/admin-page.component.tsimport { HttpErrorResponse } from '@angular/common/http'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; @@ -51,13 +52,29 @@ describe('AdminPageComponent', () => { }, ]; const api = { +<<<<<<< HEAD getPlayers: vi.fn(() => of(refreshedPlayers)), +======= + getPlayers: vi.fn(() => + of([ + { + id: 'CCCC', + type: PlayerType.Leader, + name: 'Current player', + reps: Representation.Ghost, + status: PlayerStatus.Connected, + }, + ]), + ), + registerAdmin: vi.fn(() => of(void 0)), +>>>>>>> 8c74214 (refactor: move admin page to /admin) updatePlayer: vi.fn(() => of(undefined)), updateAdminFlag: vi.fn(() => of(undefined)), resetGame: vi.fn(() => of(undefined)), }; beforeEach(async () => { +<<<<<<< HEAD adminSocket.players.set(initialPlayers.map((player) => ({ ...player }))); adminSocket.isFlagFound.set(false); adminSocket.isReady.set(true); @@ -71,6 +88,28 @@ describe('AdminPageComponent', () => { api.resetGame.mockReset(); api.resetGame.mockReturnValue(of(undefined)); +======= + adminSocket.start.mockClear(); + adminSocket.players.set([ + { + id: 'AAAA', + type: PlayerType.Player, + name: 'Ada', + reps: Representation.Pacman, + status: PlayerStatus.Connected, + }, + { + id: 'BBBB', + type: PlayerType.Player, + name: 'Ben', + reps: Representation.Nothing, + status: PlayerStatus.Disconnected, + }, + ]); + api.getPlayers.mockClear(); + api.registerAdmin.mockClear(); + api.registerAdmin.mockReturnValue(of(void 0)); +>>>>>>> 8c74214 (refactor: move admin page to /admin) await TestBed.configureTestingModule({ imports: [AdminPageComponent], providers: [{ provide: ApiService, useValue: api }], @@ -81,10 +120,9 @@ describe('AdminPageComponent', () => { .compileComponents(); fixture = TestBed.createComponent(AdminPageComponent); - fixture.detectChanges(); - await fixture.whenStable(); }); +<<<<<<< HEAD it('starts the admin player feed and preserves the new-tab map link', () => { const page = fixture.nativeElement as HTMLElement; const link = page.querySelector('.admin-map-link'); @@ -118,6 +156,72 @@ describe('AdminPageComponent', () => { }); it('renders the seven ordered type radios in independent groups', () => { +======= + it('shows the sign-in form instead of the dashboard before authentication', () => { + fixture.detectChanges(); + + const page = fixture.nativeElement as HTMLElement; + expect(page.querySelector('.auth-card')).not.toBeNull(); + expect(page.querySelector('.player-list')).toBeNull(); + expect(adminSocket.start).not.toHaveBeenCalled(); + }); + + it('starts the admin player feed after rendering when already signed in', () => { + harness().authenticated.set(true); + fixture.detectChanges(); + + expect(adminSocket.start).toHaveBeenCalledOnce(); + }); + + it('requires the administrator password before calling the API', async () => { + harness().loginModel.set({ password: '' }); + fixture.detectChanges(); + + await harness().submit(submitEvent()); + fixture.detectChanges(); + + expect(api.registerAdmin).not.toHaveBeenCalled(); + const page = fixture.nativeElement as HTMLElement; + expect(page.querySelector('.form-status')?.textContent).toContain('administrator password'); + }); + + it('keeps the sign-in form when the administrator password is incorrect', async () => { + api.registerAdmin.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 }))); + harness().loginModel.set({ password: 'wrong' }); + fixture.detectChanges(); + + await harness().submit(submitEvent()); + fixture.detectChanges(); + + expect(api.registerAdmin).toHaveBeenCalledWith('wrong'); + const page = fixture.nativeElement as HTMLElement; + expect(page.querySelector('.auth-card')).not.toBeNull(); + expect(page.querySelector('.player-list')).toBeNull(); + expect(page.querySelector('.form-status')?.textContent).toContain('incorrect'); + expect(adminSocket.start).not.toHaveBeenCalled(); + }); + + it('reveals the dashboard and starts the player feed after a successful sign-in', async () => { + harness().loginModel.set({ password: 'secret' }); + fixture.detectChanges(); + + await harness().submit(submitEvent()); + fixture.detectChanges(); + + expect(api.registerAdmin).toHaveBeenCalledWith('secret'); + const page = fixture.nativeElement as HTMLElement; + expect(page.querySelector('.auth-card')).toBeNull(); + expect(page.querySelector('.player-list')).not.toBeNull(); + expect(adminSocket.start).toHaveBeenCalledOnce(); + }); + + it('grays out disconnected players and disables their state controls', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + +>>>>>>> 8c74214 (refactor: move admin page to /admin) const page = fixture.nativeElement as HTMLElement; const cards = page.querySelectorAll('.player-card'); const expectedLabels = [ @@ -291,6 +395,9 @@ describe('AdminPageComponent', () => { }); it('can manually refresh the current player list', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); + const page = fixture.nativeElement as HTMLElement; findButton('Refresh Players')?.click(); await fixture.whenStable(); @@ -300,10 +407,25 @@ describe('AdminPageComponent', () => { expect(page.querySelector('.player-card strong')?.textContent).toContain('Current player'); }); +<<<<<<< HEAD function findButton(label: string): HTMLButtonElement | undefined { const page = fixture.nativeElement as HTMLElement; return [...page.querySelectorAll('button')].find( (button) => button.textContent?.trim() === label, ); +======= + function harness(): AdminPageHarness { + return fixture.componentInstance as unknown as AdminPageHarness; +>>>>>>> 8c74214 (refactor: move admin page to /admin) } }); + +interface AdminPageHarness { + authenticated: WritableSignal; + loginModel: WritableSignal<{ password: string }>; + submit(event: SubmitEvent): Promise; +} + +function submitEvent(): SubmitEvent { + return { preventDefault: vi.fn() } as unknown as SubmitEvent; +} diff --git a/frontend/src/app/pages/admin-page/admin-page.component.ts b/frontend/src/app/pages/admin-page/admin-page.component.ts index fb52b83..fd0c772 100644 --- a/frontend/src/app/pages/admin-page/admin-page.component.ts +++ b/frontend/src/app/pages/admin-page/admin-page.component.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { afterNextRender, ChangeDetectionStrategy, @@ -17,6 +18,11 @@ import { PlayerStatus, PlayerType, } from '../../core/game.models'; +import { BrandHeaderComponent } from '../../shared/brand-header/brand-header.component'; + +interface AdminLoginModel { + password: string; +} @Component({ selector: 'pac-admin-page', @@ -24,6 +30,9 @@ import { styleUrl: './admin-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [AdminSocketService], + host: { + '[class.admin-authenticated]': 'authenticated()', + }, }) export class AdminPageComponent { private readonly api = inject(ApiService); From b02751969b939b1dc9c86d430808631878cecbd2 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Tue, 18 Aug 2026 16:10:21 -0700 Subject: [PATCH 6/7] fix: rebase errors --- frontend/src/app/pages/_shared-auth.scss | 21 +-- .../admin-page/admin-page.component.spec.ts | 149 ++++++++---------- .../pages/admin-page/admin-page.component.ts | 45 +++++- 3 files changed, 109 insertions(+), 106 deletions(-) diff --git a/frontend/src/app/pages/_shared-auth.scss b/frontend/src/app/pages/_shared-auth.scss index a3ec6d2..0bd5c11 100644 --- a/frontend/src/app/pages/_shared-auth.scss +++ b/frontend/src/app/pages/_shared-auth.scss @@ -32,23 +32,4 @@ .form-status { min-height: 1.25rem; color: #9c0754; -} - -.radio-group { - display: flex; - gap: 1.5rem; - width: 100%; -} - -.radio-option { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - gap: 0.375rem; - border: 1px solid #808080; -} - -.radio-option:has(:checked) input { - accent-color: #9c0754; -} +} \ No newline at end of file diff --git a/frontend/src/app/pages/admin-page/admin-page.component.spec.ts b/frontend/src/app/pages/admin-page/admin-page.component.spec.ts index adfd24b..51c3536 100644 --- a/frontend/src/app/pages/admin-page/admin-page.component.spec.ts +++ b/frontend/src/app/pages/admin-page/admin-page.component.spec.ts @@ -1,5 +1,5 @@ -playerTypefrontend/src/app/pages/admin-page/admin-page.component.tsimport { HttpErrorResponse } from '@angular/common/http'; import { signal, WritableSignal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; @@ -52,29 +52,14 @@ describe('AdminPageComponent', () => { }, ]; const api = { -<<<<<<< HEAD getPlayers: vi.fn(() => of(refreshedPlayers)), -======= - getPlayers: vi.fn(() => - of([ - { - id: 'CCCC', - type: PlayerType.Leader, - name: 'Current player', - reps: Representation.Ghost, - status: PlayerStatus.Connected, - }, - ]), - ), - registerAdmin: vi.fn(() => of(void 0)), ->>>>>>> 8c74214 (refactor: move admin page to /admin) updatePlayer: vi.fn(() => of(undefined)), + registerAdmin: vi.fn(() => of(void 0)), updateAdminFlag: vi.fn(() => of(undefined)), resetGame: vi.fn(() => of(undefined)), }; beforeEach(async () => { -<<<<<<< HEAD adminSocket.players.set(initialPlayers.map((player) => ({ ...player }))); adminSocket.isFlagFound.set(false); adminSocket.isReady.set(true); @@ -83,33 +68,13 @@ describe('AdminPageComponent', () => { api.getPlayers.mockReturnValue(of(refreshedPlayers)); api.updatePlayer.mockReset(); api.updatePlayer.mockReturnValue(of(undefined)); + api.registerAdmin.mockClear(); + api.registerAdmin.mockReturnValue(of(void 0)); api.updateAdminFlag.mockReset(); api.updateAdminFlag.mockReturnValue(of(undefined)); api.resetGame.mockReset(); api.resetGame.mockReturnValue(of(undefined)); -======= - adminSocket.start.mockClear(); - adminSocket.players.set([ - { - id: 'AAAA', - type: PlayerType.Player, - name: 'Ada', - reps: Representation.Pacman, - status: PlayerStatus.Connected, - }, - { - id: 'BBBB', - type: PlayerType.Player, - name: 'Ben', - reps: Representation.Nothing, - status: PlayerStatus.Disconnected, - }, - ]); - api.getPlayers.mockClear(); - api.registerAdmin.mockClear(); - api.registerAdmin.mockReturnValue(of(void 0)); ->>>>>>> 8c74214 (refactor: move admin page to /admin) await TestBed.configureTestingModule({ imports: [AdminPageComponent], providers: [{ provide: ApiService, useValue: api }], @@ -122,55 +87,20 @@ describe('AdminPageComponent', () => { fixture = TestBed.createComponent(AdminPageComponent); }); -<<<<<<< HEAD - it('starts the admin player feed and preserves the new-tab map link', () => { - const page = fixture.nativeElement as HTMLElement; - const link = page.querySelector('.admin-map-link'); - - expect(adminSocket.connect).toHaveBeenCalledOnce(); - expect(link?.getAttribute('href')).toBe('/admin/map'); - expect(link?.getAttribute('target')).toBe('_blank'); - expect(link?.getAttribute('rel')).toBe('noopener'); - }); - - it('disables Admin mutations until the socket snapshot is ready', () => { - adminSocket.isReady.set(false); - fixture.detectChanges(); - - expect(findButton('Flag Found')?.disabled).toBe(true); - expect(findButton('Reset Game')?.disabled).toBe(true); - expect( - (fixture.nativeElement as HTMLElement).querySelector('#type-AAAA-1') - ?.disabled, - ).toBe(true); - - adminSocket.isReady.set(true); - fixture.detectChanges(); - - expect(findButton('Flag Found')?.disabled).toBe(false); - expect(findButton('Reset Game')?.disabled).toBe(false); - expect( - (fixture.nativeElement as HTMLElement).querySelector('#type-AAAA-1') - ?.disabled, - ).toBe(false); - }); - - it('renders the seven ordered type radios in independent groups', () => { -======= it('shows the sign-in form instead of the dashboard before authentication', () => { fixture.detectChanges(); const page = fixture.nativeElement as HTMLElement; expect(page.querySelector('.auth-card')).not.toBeNull(); expect(page.querySelector('.player-list')).toBeNull(); - expect(adminSocket.start).not.toHaveBeenCalled(); + expect(adminSocket.connect).not.toHaveBeenCalled(); }); it('starts the admin player feed after rendering when already signed in', () => { harness().authenticated.set(true); fixture.detectChanges(); - expect(adminSocket.start).toHaveBeenCalledOnce(); + expect(adminSocket.connect).toHaveBeenCalledOnce(); }); it('requires the administrator password before calling the API', async () => { @@ -198,7 +128,7 @@ describe('AdminPageComponent', () => { expect(page.querySelector('.auth-card')).not.toBeNull(); expect(page.querySelector('.player-list')).toBeNull(); expect(page.querySelector('.form-status')?.textContent).toContain('incorrect'); - expect(adminSocket.start).not.toHaveBeenCalled(); + expect(adminSocket.connect).not.toHaveBeenCalled(); }); it('reveals the dashboard and starts the player feed after a successful sign-in', async () => { @@ -212,16 +142,49 @@ describe('AdminPageComponent', () => { const page = fixture.nativeElement as HTMLElement; expect(page.querySelector('.auth-card')).toBeNull(); expect(page.querySelector('.player-list')).not.toBeNull(); - expect(adminSocket.start).toHaveBeenCalledOnce(); + expect(adminSocket.connect).toHaveBeenCalledOnce(); }); - it('grays out disconnected players and disables their state controls', async () => { + it('starts the admin player feed and preserves the new-tab map link', () => { harness().authenticated.set(true); fixture.detectChanges(); - await fixture.whenStable(); + + const page = fixture.nativeElement as HTMLElement; + const link = page.querySelector('.admin-map-link'); + + expect(adminSocket.connect).toHaveBeenCalledOnce(); + expect(link?.getAttribute('href')).toBe('/admin/map'); + expect(link?.getAttribute('target')).toBe('_blank'); + expect(link?.getAttribute('rel')).toBe('noopener'); + }); + + it('disables Admin mutations until the socket snapshot is ready', () => { + harness().authenticated.set(true); + adminSocket.isReady.set(false); + fixture.detectChanges(); + + expect(findButton('Flag Found')?.disabled).toBe(true); + expect(findButton('Reset Game')?.disabled).toBe(true); + expect( + (fixture.nativeElement as HTMLElement).querySelector('#type-AAAA-1') + ?.disabled, + ).toBe(true); + + adminSocket.isReady.set(true); + fixture.detectChanges(); + + expect(findButton('Flag Found')?.disabled).toBe(false); + expect(findButton('Reset Game')?.disabled).toBe(false); + expect( + (fixture.nativeElement as HTMLElement).querySelector('#type-AAAA-1') + ?.disabled, + ).toBe(false); + }); + + it('renders the seven ordered type radios in independent groups', () => { + harness().authenticated.set(true); fixture.detectChanges(); ->>>>>>> 8c74214 (refactor: move admin page to /admin) const page = fixture.nativeElement as HTMLElement; const cards = page.querySelectorAll('.player-card'); const expectedLabels = [ @@ -255,6 +218,9 @@ describe('AdminPageComponent', () => { }); it('shows Edible as Ghost-selected and disables offline player radios', () => { + harness().authenticated.set(true); + fixture.detectChanges(); + const page = fixture.nativeElement as HTMLElement; const cards = page.querySelectorAll('.player-card'); const connectedRadios = cards[0].querySelectorAll('input[type="radio"]'); @@ -269,6 +235,9 @@ describe('AdminPageComponent', () => { }); it('applies a connected player radio selection immediately', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); + const page = fixture.nativeElement as HTMLElement; const antipac = page.querySelector('#type-AAAA-2'); @@ -283,6 +252,9 @@ describe('AdminPageComponent', () => { }); it('demotes the existing Pacman when another player is selected as Pacman', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); + const page = fixture.nativeElement as HTMLElement; page.querySelector('#type-CCCC-1')?.click(); await fixture.whenStable(); @@ -298,6 +270,7 @@ describe('AdminPageComponent', () => { }); it('demotes only the previous holder when assigning a specialized leader role', async () => { + harness().authenticated.set(true); adminSocket.players.set([ { id: 'AAAA', @@ -328,6 +301,8 @@ describe('AdminPageComponent', () => { }); it('restores server-backed selection when an immediate update fails', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); api.updatePlayer.mockReturnValueOnce(throwError(() => new Error('update failed'))); const page = fixture.nativeElement as HTMLElement; page.querySelector('#type-AAAA-3')?.click(); @@ -342,6 +317,9 @@ describe('AdminPageComponent', () => { }); it('replaces the Ghost mutation with a shared Flag Found toggle', async () => { + harness().authenticated.set(true); + fixture.detectChanges(); + const button = findButton('Flag Found'); expect(button?.getAttribute('aria-pressed')).toBe('false'); expect(button?.classList.contains('button-secondary')).toBe(true); @@ -357,6 +335,7 @@ describe('AdminPageComponent', () => { }); it('rolls back a failed Admin flag update', async () => { + harness().authenticated.set(true); adminSocket.isFlagFound.set(true); api.updateAdminFlag.mockReturnValueOnce(throwError(() => new Error('update failed'))); fixture.detectChanges(); @@ -375,7 +354,10 @@ describe('AdminPageComponent', () => { }); it('resets connected and offline non-Leaders to Ghost while preserving Leaders', async () => { + harness().authenticated.set(true); adminSocket.isFlagFound.set(true); + fixture.detectChanges(); + const button = findButton('Reset Game'); button?.click(); await fixture.whenStable(); @@ -407,16 +389,15 @@ describe('AdminPageComponent', () => { expect(page.querySelector('.player-card strong')?.textContent).toContain('Current player'); }); -<<<<<<< HEAD function findButton(label: string): HTMLButtonElement | undefined { const page = fixture.nativeElement as HTMLElement; return [...page.querySelectorAll('button')].find( (button) => button.textContent?.trim() === label, ); -======= + } + function harness(): AdminPageHarness { return fixture.componentInstance as unknown as AdminPageHarness; ->>>>>>> 8c74214 (refactor: move admin page to /admin) } }); diff --git a/frontend/src/app/pages/admin-page/admin-page.component.ts b/frontend/src/app/pages/admin-page/admin-page.component.ts index fd0c772..1803476 100644 --- a/frontend/src/app/pages/admin-page/admin-page.component.ts +++ b/frontend/src/app/pages/admin-page/admin-page.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { afterNextRender, ChangeDetectionStrategy, @@ -7,7 +6,9 @@ import { inject, signal, } from '@angular/core'; +import { form, FormField, required, submit as submitForm } from '@angular/forms/signals'; import { firstValueFrom } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { AdminSocketService } from '../../core/sockets/admin-socket.service'; import { ApiService } from '../../core/api.service'; @@ -26,6 +27,7 @@ interface AdminLoginModel { @Component({ selector: 'pac-admin-page', + imports: [FormField, BrandHeaderComponent], templateUrl: './admin-page.component.html', styleUrl: './admin-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -42,6 +44,7 @@ export class AdminPageComponent { protected readonly isFlagFound = this.adminSocket.isFlagFound; protected readonly socketReady = this.adminSocket.isReady; protected readonly connectionStatus = this.adminSocket.status; + protected readonly authenticated = signal(false); protected readonly status = signal(''); protected readonly loadingPlayers = signal(false); protected readonly bulkUpdating = signal(false); @@ -57,8 +60,46 @@ export class AdminPageComponent { this.savingPlayerIds().size > 0, ); + protected readonly loginModel = signal({ password: '' }); + + protected readonly loginForm = form(this.loginModel, (login) => { + required(login.password, { message: 'Enter the administrator password.' }); + }); + constructor() { - afterNextRender(() => this.adminSocket.connect()); + afterNextRender(() => { + if (this.authenticated()) { + this.adminSocket.connect(); + } + }); + } + + protected async submit(event: SubmitEvent): Promise { + event.preventDefault(); + await submitForm(this.loginForm, { + action: async () => this.login(), + onInvalid: () => { + const firstError = this.loginForm().errorSummary()[0]; + this.status.set(firstError?.message ?? 'Enter the administrator password.'); + }, + }); + } + + private async login(): Promise { + const { password } = this.loginForm().value(); + this.status.set('Signing in...'); + + try { + await firstValueFrom(this.api.registerAdmin(password)); + this.authenticated.set(true); + this.adminSocket.connect(); + } catch (error) { + if (error instanceof HttpErrorResponse && error.status === 401) { + this.status.set('The administrator password is incorrect.'); + } else { + this.status.set('Could not sign in. Check the password and the API connection.'); + } + } } protected isConnected(player: Player): boolean { From 923443f4bac4a26e34224a08b88fe6268bef27b0 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Tue, 18 Aug 2026 23:04:40 -0700 Subject: [PATCH 7/7] fix: remove dead code --- frontend/src/app/core/game.models.ts | 5 ----- .../register-page.component.spec.ts | 4 ---- .../register-page/register-page.component.ts | 20 ++----------------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/core/game.models.ts b/frontend/src/app/core/game.models.ts index c0ac733..54a801c 100644 --- a/frontend/src/app/core/game.models.ts +++ b/frontend/src/app/core/game.models.ts @@ -27,11 +27,6 @@ export enum PlayerType { FlagLeader = 7, } -export enum RegistrationKind { - Player = 'player', - Admin = 'admin', -} - export enum PlayerStatus { Gone = 0, Disconnected = 1, diff --git a/frontend/src/app/pages/register-page/register-page.component.spec.ts b/frontend/src/app/pages/register-page/register-page.component.spec.ts index 88dcd22..e560a89 100644 --- a/frontend/src/app/pages/register-page/register-page.component.spec.ts +++ b/frontend/src/app/pages/register-page/register-page.component.spec.ts @@ -5,7 +5,6 @@ import { of } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { CredentialsService } from '../../core/credentials.service'; -import { RegistrationKind } from '../../core/game.models'; import { RegisterPageComponent } from './register-page.component'; describe('RegisterPageComponent', () => { @@ -32,7 +31,6 @@ describe('RegisterPageComponent', () => { const component = TestBed.createComponent(RegisterPageComponent) .componentInstance as unknown as RegisterPageHarness; component.registrationModel.set({ - registrationKind: RegistrationKind.Player, name: 'Test2', }); @@ -48,7 +46,6 @@ describe('RegisterPageComponent', () => { const component = TestBed.createComponent(RegisterPageComponent) .componentInstance as unknown as RegisterPageHarness; component.registrationModel.set({ - registrationKind: RegistrationKind.Player, name: ' ', }); @@ -60,7 +57,6 @@ describe('RegisterPageComponent', () => { interface RegisterPageHarness { registrationModel: WritableSignal<{ - registrationKind: RegistrationKind; name: string; }>; submit(event: SubmitEvent): Promise; diff --git a/frontend/src/app/pages/register-page/register-page.component.ts b/frontend/src/app/pages/register-page/register-page.component.ts index fd4d4c6..c04ca41 100644 --- a/frontend/src/app/pages/register-page/register-page.component.ts +++ b/frontend/src/app/pages/register-page/register-page.component.ts @@ -1,12 +1,9 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { form, FormField, - hidden, maxLength, pattern, - required, submit as submitForm, } from '@angular/forms/signals'; import { Router } from '@angular/router'; @@ -14,11 +11,9 @@ import { firstValueFrom } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { CredentialsService } from '../../core/credentials.service'; -import { RegistrationKind } from '../../core/game.models'; import { BrandHeaderComponent } from '../../shared/brand-header/brand-header.component'; interface RegistrationModel { - registrationKind: RegistrationKind; name: string; } @@ -35,16 +30,10 @@ export class RegisterPageComponent { private readonly router = inject(Router); protected readonly registrationModel = signal({ - registrationKind: RegistrationKind.Player, name: '', }); protected readonly registrationForm = form(this.registrationModel, (registration) => { - required(registration.registrationKind); - required(registration.name, { message: 'Enter your name.' }); - hidden(registration.name, { - when: ({ valueOf }) => valueOf(registration.registrationKind) === RegistrationKind.Admin, - }); pattern(registration.name, /\S/, { message: 'Enter your name.' }); maxLength(registration.name, 80, { message: 'Your name must be 80 characters or fewer.' }); }); @@ -63,8 +52,7 @@ export class RegisterPageComponent { } private async register(): Promise { - const { registrationKind, name } = this.registrationForm().value(); - const trimmedName = name.trim(); + const trimmedName = this.registrationForm().value().name.trim(); this.status.set('Registering...'); try { @@ -76,11 +64,7 @@ export class RegisterPageComponent { this.credentials.save({ id }); await this.router.navigateByUrl('/'); } catch (error) { - if (error instanceof HttpErrorResponse && error.status === 401) { - this.status.set('The administrator password is incorrect.'); - } else { - this.status.set('Registration failed. Check your details and the API connection.'); - } + this.status.set('Registration failed. Check your details and the API connection.'); } } }