Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
const adminCookieName = "pacmacro_admin"

type AdminRegistrationRequest struct {
Name string `json:"name"`
Pass string `json:"pass"`
}

Expand All @@ -35,7 +34,6 @@ type Admin struct {

password string
cookieValue string
name string
registered bool
stateMutex sync.RWMutex
socketMutex sync.Mutex
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()

Expand All @@ -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/<ID>
Expand Down
8 changes: 3 additions & 5 deletions api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"testing"
)

const testUser = "test"

type recordingAdminConnection struct {
messages [][]byte
closed bool
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ Player sessions use only the ID returned by registration; the browser stores tha

## Administrator authentication

Select **Admin** on the registration page and enter the password configured as `ADMIN_PASSWORD` on the Go backend.
The backend verifies it once and returns a session-scoped HttpOnly cookie.
Open `/admin` and enter the password configured as `ADMIN_PASSWORD` on the Go backend.
The backend verifies it and returns a session-scoped HttpOnly cookie.
The Angular application cannot read the cookie; the browser sends it automatically to `/api/admin` requests.

The admin control panel opens `/admin/map` in a separate tab. That page connects to the authenticated `/api/admin/map/ws` endpoint and receives the normal live game feed without creating a player or sending coordinates. If the session cookie has expired, register as admin again before reopening the map.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/core/api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/app/core/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,8 @@ export class ApiService {
});
}

registerAdmin(name: string, password: string): Observable<void> {
return this.http.post<void>(
'/api/admin/register',
{ name, pass: password },
{ withCredentials: true },
);
registerAdmin(password: string): Observable<void> {
return this.http.post<void>('/api/admin/register', { pass: password }, { withCredentials: true });
}

getPlayers(): Observable<Player[]> {
Expand Down
5 changes: 0 additions & 5 deletions frontend/src/app/core/game.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,6 @@ export enum PlayerType {
FlagLeader = 7,
}

export enum RegistrationKind {
Player = 'player',
Admin = 'admin',
}

export enum PlayerStatus {
Gone = 0,
Disconnected = 1,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/pages/_shared-auth.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@
.form-status {
min-height: 1.25rem;
color: #9c0754;
}
}
150 changes: 88 additions & 62 deletions frontend/src/app/pages/admin-page/admin-page.component.html
Original file line number Diff line number Diff line change
@@ -1,66 +1,92 @@
<main class="admin-page">
<header>
<p class="eyebrow">PacMacro control panel</p>
<h1>Admin</h1>
</header>
@if (!authenticated()) {
<pac-brand-header />

<p class="admin-connection-status" role="status">Websocket: {{ connectionStatus() }}</p>
<p class="admin-status" role="status" aria-live="polite">{{ status() }}</p>
<main class="auth-page">
<form class="auth-card" (submit)="submit($event)">
<h1>Admin sign in</h1>

<section class="admin-actions" aria-label="Admin actions">
<a class="button admin-map-link" href="/admin/map" target="_blank" rel="noopener">Open Map</a>
<button
type="button"
class="flag-control"
[class.button-secondary]="!isFlagFound()"
[attr.aria-pressed]="isFlagFound()"
[disabled]="updatesInProgress()"
(click)="toggleFlagFound()"
>
Flag Found
</button>
<button type="button" [disabled]="updatesInProgress()" (click)="resetGame()">Reset Game</button>
<button
type="button"
class="button-secondary"
[disabled]="loadingPlayers()"
(click)="refreshPlayers()"
>
{{ loadingPlayers() ? 'Fetching…' : 'Refresh Players' }}
</button>
</section>
<hr />
<label for="admin-password">Administrator password</label>
<input
id="admin-password"
type="password"
autocomplete="current-password"
[formField]="loginForm.password"
/>

<section class="player-list" aria-label="Players">
@for (player of players(); track player.id) {
<article class="player-card" [class.player-card--offline]="!isConnected(player)">
<div class="player-card__identity">
<strong>{{ player.name }}</strong>
<span>{{ player.id }}</span>
<span class="player-card__status">
{{ isConnected(player) ? 'Connected' : 'Offline' }}
</span>
</div>
<button type="submit" [disabled]="loginForm().submitting()">
{{ loginForm().submitting() ? 'Signing in…' : 'Sign in' }}
</button>
<p class="form-status" role="status" aria-live="polite">{{ status() }}</p>
</form>
</main>
} @else {
<main class="admin-page">
<header>
<p class="eyebrow">PacMacro control panel</p>
<h1>Admin</h1>
</header>

<div class="player-types" role="radiogroup" [attr.aria-label]="'Type for ' + player.name">
@for (playerType of playerTypes; track playerType.value) {
<input
[id]="'type-' + player.id + '-' + playerType.value"
class="player-type"
type="radio"
[name]="'type-' + player.id"
[value]="playerType.value"
[disabled]="updatesInProgress() || isPlayerControlDisabled(player)"
[checked]="isTypeSelected(player, playerType.value)"
(change)="updateType(player, playerType.value, $event)"
/>
<label class="button" [for]="'type-' + player.id + '-' + playerType.value">
{{ playerType.label }}
</label>
}
</div>
</article>
} @empty {
<p class="player-list__empty">Players will appear here when they register.</p>
}
</section>
</main>
<p class="admin-connection-status" role="status">Websocket: {{ connectionStatus() }}</p>
<p class="admin-status" role="status" aria-live="polite">{{ status() }}</p>

<section class="admin-actions" aria-label="Admin actions">
<a class="button admin-map-link" href="/admin/map" target="_blank" rel="noopener">Open Map</a>
<button
type="button"
class="flag-control"
[class.button-secondary]="!isFlagFound()"
[attr.aria-pressed]="isFlagFound()"
[disabled]="updatesInProgress()"
(click)="toggleFlagFound()"
>
Flag Found
</button>
<button type="button" [disabled]="updatesInProgress()" (click)="resetGame()">
Reset Game
</button>
<button
type="button"
class="button-secondary"
[disabled]="loadingPlayers()"
(click)="refreshPlayers()"
>
{{ loadingPlayers() ? 'Fetching…' : 'Refresh Players' }}
</button>
</section>

<section class="player-list" aria-label="Players">
@for (player of players(); track player.id) {
<article class="player-card" [class.player-card--offline]="!isConnected(player)">
<div class="player-card__identity">
<strong>{{ player.name }}</strong>
<span>{{ player.id }}</span>
<span class="player-card__status">
{{ isConnected(player) ? 'Connected' : 'Offline' }}
</span>
</div>

<div class="player-types" role="radiogroup" [attr.aria-label]="'Type for ' + player.name">
@for (playerType of playerTypes; track playerType.value) {
<input
[id]="'type-' + player.id + '-' + playerType.value"
class="player-type"
type="radio"
[name]="'type-' + player.id"
[value]="playerType.value"
[disabled]="updatesInProgress() || isPlayerControlDisabled(player)"
[checked]="isTypeSelected(player, playerType.value)"
(change)="updateType(player, playerType.value, $event)"
/>
<label class="button" [for]="'type-' + player.id + '-' + playerType.value">
{{ playerType.label }}
</label>
}
</div>
</article>
} @empty {
<p class="player-list__empty">Players will appear here when they register.</p>
}
</section>
</main>
}
5 changes: 5 additions & 0 deletions frontend/src/app/pages/admin-page/admin-page.component.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
@use '../shared-auth';

:host {
display: block;
}

:host.admin-authenticated {
min-height: 100dvh;
background: #f5f5f7;
color: #111;
Expand Down
Loading