AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 (#20)

This commit was merged in pull request #20.
This commit is contained in:
2026-07-22 10:35:27 +00:00
parent 90e570c43c
commit de527ec6d6
15 changed files with 688 additions and 56 deletions
+131 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
@@ -7,6 +7,14 @@ import {
clearStoredSession,
StoredSession,
} from './auth.session-storage';
import {
CROSS_TAB_CHANNEL_NAME,
CROSS_TAB_STORAGE_KEY,
CrossTabInvalidationMessage,
encodeInvalidationMessage,
isCrossTabInvalidationMessage,
isStorageInvalidationEvent,
} from './auth-session-events.pure';
export interface CurrentUser {
id: string;
@@ -78,9 +86,22 @@ export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure
@Injectable({ providedIn: 'root' })
export class AuthService {
private http: HttpClient;
private readonly destroyRef = inject(DestroyRef);
private readonly crossTabOrigin =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
private channel: BroadcastChannel | null = null;
private storageListener: ((ev: StorageEvent) => void) | null = null;
private suppressBroadcast = false;
private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>();
constructor(http?: HttpClient) {
this.http = http ?? inject(HttpClient);
this.installCrossTabListener();
this.destroyRef.onDestroy(() => this.teardownCrossTabListener());
}
private accessToken = signal<string | null>(null);
@@ -158,7 +179,7 @@ export class AuthService {
} catch {
// ignore network/auth errors; we still clear local state
}
this.clear();
this.clearAndBroadcast();
}
async me(): Promise<MeResponse> {
@@ -204,10 +225,118 @@ export class AuthService {
clearStoredSession(this.sessionStorage());
}
clearAndBroadcast(): void {
this.clear();
if (this.suppressBroadcast) return;
const message = encodeInvalidationMessage(this.crossTabOrigin);
this.publishInvalidation(message);
}
handleSseUnauthorized(): void {
this.clearAndBroadcast();
this.notifyPeerInvalidation('sse-unauthorized');
}
onPeerInvalidation(cb: (reason: 'peer-logout' | 'sse-unauthorized') => void): () => void {
this.peerInvalidationListeners.add(cb);
return () => this.peerInvalidationListeners.delete(cb);
}
getAccessToken(): string | null {
return this.accessToken();
}
private publishInvalidation(message: CrossTabInvalidationMessage): void {
if (this.channel) {
try {
this.channel.postMessage(message);
} catch {
// ignore serialization failures; the storage fallback will still fire
}
}
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.setItem(CROSS_TAB_STORAGE_KEY, JSON.stringify(message));
} catch {
// ignore quota / serialization errors
}
}
}
private installCrossTabListener(): void {
if (typeof window === 'undefined') return;
if (typeof BroadcastChannel !== 'undefined') {
try {
this.channel = new BroadcastChannel(CROSS_TAB_CHANNEL_NAME);
this.channel.addEventListener('message', this.onBroadcastMessage);
} catch {
this.channel = null;
}
}
this.storageListener = (ev: StorageEvent) => {
if (!isStorageInvalidationEvent(ev)) return;
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
};
window.addEventListener('storage', this.storageListener);
}
private teardownCrossTabListener(): void {
if (this.channel) {
try {
this.channel.removeEventListener('message', this.onBroadcastMessage);
this.channel.close();
} catch {
// ignore
}
this.channel = null;
}
if (this.storageListener && typeof window !== 'undefined') {
try {
window.removeEventListener('storage', this.storageListener);
} catch {
// ignore
}
this.storageListener = null;
}
this.peerInvalidationListeners.clear();
}
private readonly onBroadcastMessage = (ev: MessageEvent<unknown>) => {
if (!isCrossTabInvalidationMessage(ev.data)) return;
if (ev.data.origin === this.crossTabOrigin) return;
this.handlePeerInvalidation(JSON.stringify(ev.data), 'peer-logout');
};
private handlePeerInvalidation(
raw: string | null,
reason: 'peer-logout' | 'sse-unauthorized',
): void {
if (raw) {
try {
const parsed = JSON.parse(raw) as unknown;
if (!isCrossTabInvalidationMessage(parsed)) return;
if (parsed.origin === this.crossTabOrigin) return;
} catch {
return;
}
}
if (!this.isAuthenticated()) return;
this.suppressBroadcast = true;
this.clear();
this.suppressBroadcast = false;
this.notifyPeerInvalidation(reason);
}
private notifyPeerInvalidation(reason: 'peer-logout' | 'sse-unauthorized'): void {
for (const cb of this.peerInvalidationListeners) {
try {
cb(reason);
} catch {
// ignore listener errors
}
}
}
private sessionStorage(): Storage {
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
}