AI Implementation feature(913): Admin Area Players Management 1.00 (#57)
This commit was merged in pull request #57.
This commit is contained in:
@@ -2,12 +2,24 @@ export const CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1';
|
||||
export const CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1';
|
||||
export const CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated';
|
||||
|
||||
export const CROSS_TAB_ROLE_CHANNEL_NAME = 'hipctf.auth.role.v1';
|
||||
export const CROSS_TAB_ROLE_STORAGE_KEY = 'hipctf.role.invalidate.v1';
|
||||
export const CROSS_TAB_ROLE_EVENT_TYPE = 'hipctf.session.role-changed';
|
||||
|
||||
export interface CrossTabInvalidationMessage {
|
||||
type: 'session-invalidated';
|
||||
origin: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface CrossTabRoleChangeMessage {
|
||||
type: 'role-changed';
|
||||
origin: string;
|
||||
ts: number;
|
||||
userId: string;
|
||||
newRole: 'admin' | 'player';
|
||||
}
|
||||
|
||||
export function encodeInvalidationMessage(origin: string): CrossTabInvalidationMessage {
|
||||
return {
|
||||
type: 'session-invalidated',
|
||||
@@ -16,6 +28,20 @@ export function encodeInvalidationMessage(origin: string): CrossTabInvalidationM
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeRoleChangeMessage(
|
||||
origin: string,
|
||||
userId: string,
|
||||
newRole: 'admin' | 'player',
|
||||
): CrossTabRoleChangeMessage {
|
||||
return {
|
||||
type: 'role-changed',
|
||||
origin,
|
||||
ts: Date.now(),
|
||||
userId,
|
||||
newRole,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCrossTabInvalidationMessage(
|
||||
value: unknown,
|
||||
): value is CrossTabInvalidationMessage {
|
||||
@@ -28,6 +54,20 @@ export function isCrossTabInvalidationMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function isCrossTabRoleChangeMessage(
|
||||
value: unknown,
|
||||
): value is CrossTabRoleChangeMessage {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
v['type'] === 'role-changed' &&
|
||||
typeof v['origin'] === 'string' &&
|
||||
typeof v['ts'] === 'number' &&
|
||||
typeof v['userId'] === 'string' &&
|
||||
(v['newRole'] === 'admin' || v['newRole'] === 'player')
|
||||
);
|
||||
}
|
||||
|
||||
export function isStorageInvalidationEvent(
|
||||
event: { key?: string | null; newValue?: string | null } | null | undefined,
|
||||
): boolean {
|
||||
@@ -41,3 +81,26 @@ export function isStorageInvalidationEvent(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isStorageRoleChangeEvent(
|
||||
event: { key?: string | null; newValue?: string | null } | null | undefined,
|
||||
): boolean {
|
||||
if (!event) return false;
|
||||
if (event.key !== CROSS_TAB_ROLE_STORAGE_KEY) return false;
|
||||
if (event.newValue == null) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(event.newValue) as unknown;
|
||||
return isCrossTabRoleChangeMessage(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRoleChangeToUser<T extends { id: string; role: 'admin' | 'player' } | null>(
|
||||
user: T,
|
||||
newRole: 'admin' | 'player',
|
||||
): T {
|
||||
if (!user) return user;
|
||||
if (user.role === newRole) return user;
|
||||
return { ...user, role: newRole };
|
||||
}
|
||||
|
||||
@@ -10,10 +10,16 @@ import {
|
||||
import {
|
||||
CROSS_TAB_CHANNEL_NAME,
|
||||
CROSS_TAB_STORAGE_KEY,
|
||||
CROSS_TAB_ROLE_CHANNEL_NAME,
|
||||
CROSS_TAB_ROLE_STORAGE_KEY,
|
||||
CrossTabInvalidationMessage,
|
||||
CrossTabRoleChangeMessage,
|
||||
encodeInvalidationMessage,
|
||||
encodeRoleChangeMessage,
|
||||
isCrossTabInvalidationMessage,
|
||||
isCrossTabRoleChangeMessage,
|
||||
isStorageInvalidationEvent,
|
||||
isStorageRoleChangeEvent,
|
||||
} from './auth-session-events.pure';
|
||||
|
||||
export interface CurrentUser {
|
||||
@@ -93,10 +99,12 @@ export class AuthService {
|
||||
? crypto.randomUUID()
|
||||
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
||||
private channel: BroadcastChannel | null = null;
|
||||
private roleChannel: BroadcastChannel | null = null;
|
||||
private storageListener: ((ev: StorageEvent) => void) | null = null;
|
||||
private suppressBroadcast = false;
|
||||
|
||||
private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>();
|
||||
private peerRoleChangeListeners = new Set<(msg: CrossTabRoleChangeMessage) => void>();
|
||||
|
||||
constructor(http?: HttpClient) {
|
||||
this.http = http ?? inject(HttpClient);
|
||||
@@ -242,6 +250,44 @@ export class AuthService {
|
||||
return () => this.peerInvalidationListeners.delete(cb);
|
||||
}
|
||||
|
||||
onPeerRoleChange(cb: (msg: CrossTabRoleChangeMessage) => void): () => void {
|
||||
this.peerRoleChangeListeners.add(cb);
|
||||
return () => this.peerRoleChangeListeners.delete(cb);
|
||||
}
|
||||
|
||||
applyPeerRoleChange(newRole: 'admin' | 'player'): void {
|
||||
const current = this.user();
|
||||
if (!current) return;
|
||||
if (current.role === newRole) return;
|
||||
const updated = { ...current, role: newRole };
|
||||
this.user.set(updated);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
writeStoredSession(this.sessionStorage(), { token: this.accessToken() ?? '', user: updated });
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishRoleChange(userId: string, newRole: 'admin' | 'player'): void {
|
||||
const message = encodeRoleChangeMessage(this.crossTabOrigin, userId, newRole);
|
||||
if (this.roleChannel) {
|
||||
try {
|
||||
this.roleChannel.postMessage(message);
|
||||
} catch {
|
||||
// ignore serialization failures; storage fallback still fires
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
try {
|
||||
window.localStorage.setItem(CROSS_TAB_ROLE_STORAGE_KEY, JSON.stringify(message));
|
||||
} catch {
|
||||
// ignore quota / serialization errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken();
|
||||
}
|
||||
@@ -272,10 +318,21 @@ export class AuthService {
|
||||
} catch {
|
||||
this.channel = null;
|
||||
}
|
||||
try {
|
||||
this.roleChannel = new BroadcastChannel(CROSS_TAB_ROLE_CHANNEL_NAME);
|
||||
this.roleChannel.addEventListener('message', this.onRoleBroadcastMessage);
|
||||
} catch {
|
||||
this.roleChannel = null;
|
||||
}
|
||||
}
|
||||
this.storageListener = (ev: StorageEvent) => {
|
||||
if (!isStorageInvalidationEvent(ev)) return;
|
||||
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
|
||||
if (isStorageInvalidationEvent(ev)) {
|
||||
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
|
||||
return;
|
||||
}
|
||||
if (isStorageRoleChangeEvent(ev)) {
|
||||
this.handlePeerRoleChange(ev.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
}
|
||||
@@ -290,6 +347,15 @@ export class AuthService {
|
||||
}
|
||||
this.channel = null;
|
||||
}
|
||||
if (this.roleChannel) {
|
||||
try {
|
||||
this.roleChannel.removeEventListener('message', this.onRoleBroadcastMessage);
|
||||
this.roleChannel.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.roleChannel = null;
|
||||
}
|
||||
if (this.storageListener && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.removeEventListener('storage', this.storageListener);
|
||||
@@ -299,6 +365,7 @@ export class AuthService {
|
||||
this.storageListener = null;
|
||||
}
|
||||
this.peerInvalidationListeners.clear();
|
||||
this.peerRoleChangeListeners.clear();
|
||||
}
|
||||
|
||||
private readonly onBroadcastMessage = (ev: MessageEvent<unknown>) => {
|
||||
@@ -307,6 +374,12 @@ export class AuthService {
|
||||
this.handlePeerInvalidation(JSON.stringify(ev.data), 'peer-logout');
|
||||
};
|
||||
|
||||
private readonly onRoleBroadcastMessage = (ev: MessageEvent<unknown>) => {
|
||||
if (!isCrossTabRoleChangeMessage(ev.data)) return;
|
||||
if (ev.data.origin === this.crossTabOrigin) return;
|
||||
this.handlePeerRoleChange(JSON.stringify(ev.data));
|
||||
};
|
||||
|
||||
private handlePeerInvalidation(
|
||||
raw: string | null,
|
||||
reason: 'peer-logout' | 'sse-unauthorized',
|
||||
@@ -337,6 +410,31 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private handlePeerRoleChange(raw: string | null): void {
|
||||
if (!raw) return;
|
||||
let parsed: CrossTabRoleChangeMessage | null = null;
|
||||
try {
|
||||
const candidate = JSON.parse(raw) as unknown;
|
||||
if (!isCrossTabRoleChangeMessage(candidate)) return;
|
||||
if (candidate.origin === this.crossTabOrigin) return;
|
||||
parsed = candidate;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
const me = this.user();
|
||||
if (!me) return;
|
||||
if (me.id !== parsed.userId) return;
|
||||
this.applyPeerRoleChange(parsed.newRole);
|
||||
for (const cb of this.peerRoleChangeListeners) {
|
||||
try {
|
||||
cb(parsed);
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sessionStorage(): Storage {
|
||||
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
|
||||
}
|
||||
|
||||
@@ -52,4 +52,12 @@ export class UserStore {
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
setRole(role: 'admin' | 'player'): void {
|
||||
const u = this.user();
|
||||
if (!u) return;
|
||||
if (u.role === role) return;
|
||||
this.user.set({ ...u, role });
|
||||
this.auth.applyPeerRoleChange(role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminService, AdminUser } from '../../core/services/admin.service';
|
||||
import { NotificationService } from '../../core/services/notification.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import {
|
||||
adminPlayerApiErrorMessage,
|
||||
extractFieldErrors,
|
||||
@@ -169,6 +170,7 @@ import {
|
||||
export class AdminUsersComponent implements OnInit {
|
||||
private readonly admin = inject(AdminService);
|
||||
private readonly notify = inject(NotificationService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
readonly rows = signal<AdminUser[]>([]);
|
||||
readonly loading = signal(false);
|
||||
@@ -241,6 +243,11 @@ export class AdminUsersComponent implements OnInit {
|
||||
const updated = await this.withInflight(row.id, () => this.admin.updatePlayerRole(row.id, nextRole));
|
||||
this.rows.set(replacePlayer(this.rows(), { ...row, ...updated }));
|
||||
this.statusMessage.set(`Role updated to ${updated.role}.`);
|
||||
const me = this.auth.currentUser();
|
||||
if (me && me.id === updated.id && me.role !== updated.role) {
|
||||
this.auth.applyPeerRoleChange(updated.role);
|
||||
this.auth.publishRoleChange(updated.id, updated.role);
|
||||
}
|
||||
} catch (e) {
|
||||
if (isLastAdminError(e)) {
|
||||
this.notify.error(lastAdminMessage());
|
||||
|
||||
@@ -17,6 +17,8 @@ import { EventStatusStore } from '../../core/services/event-status.store';
|
||||
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
|
||||
import { UserStore } from '../../core/services/user.store';
|
||||
import { ChallengesStore } from '../challenges/challenges.store';
|
||||
import { NotificationService } from '../../core/services/notification.service';
|
||||
import { CrossTabRoleChangeMessage } from '../../core/services/auth-session-events.pure';
|
||||
import { ShellHeaderComponent } from '../shell/header/shell-header.component';
|
||||
import {
|
||||
ChangePasswordModalComponent,
|
||||
@@ -95,6 +97,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
private readonly challengesStore = inject(ChallengesStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly notifications = inject(NotificationService);
|
||||
|
||||
readonly userMenuOpen = signal(false);
|
||||
readonly changePasswordOpen = signal(false);
|
||||
@@ -138,6 +141,9 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) =>
|
||||
this.handleSessionInvalidated(reason),
|
||||
);
|
||||
this.peerRoleChangeUnsubscribe = this.auth.onPeerRoleChange((msg) =>
|
||||
this.handlePeerRoleChange(msg),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -146,6 +152,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.peerInvalidationUnsubscribe();
|
||||
this.peerInvalidationUnsubscribe = null;
|
||||
}
|
||||
if (this.peerRoleChangeUnsubscribe) {
|
||||
this.peerRoleChangeUnsubscribe();
|
||||
this.peerRoleChangeUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
goHome(): void {
|
||||
@@ -207,6 +217,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private peerInvalidationUnsubscribe: (() => void) | null = null;
|
||||
private peerRoleChangeUnsubscribe: (() => void) | null = null;
|
||||
private navigatingToLogin = false;
|
||||
|
||||
private async handleSessionInvalidated(
|
||||
@@ -225,4 +236,14 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.navigatingToLogin = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePeerRoleChange(msg: CrossTabRoleChangeMessage): void {
|
||||
const me = this.auth.currentUser();
|
||||
if (!me || me.id !== msg.userId) return;
|
||||
this.userStore.setRole(msg.newRole);
|
||||
if (!this.router.url.startsWith('/admin/')) return;
|
||||
this.notifications.info(
|
||||
`Your role was changed to "${msg.newRole}" by another admin. You may need to refresh or log in again to continue.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user