|
@@ -10,17 +10,14 @@ export class AdminSessionService {
|
|
|
private readonly http = inject(HttpClient);
|
|
private readonly http = inject(HttpClient);
|
|
|
private readonly router = inject(Router);
|
|
private readonly router = inject(Router);
|
|
|
private readonly sessionChecks = new Map<string, Promise<void>>();
|
|
private readonly sessionChecks = new Map<string, Promise<void>>();
|
|
|
|
|
+ private sharedStorageWritable = false;
|
|
|
private readonly state = signal<AdminSession | null>(this.restore());
|
|
private readonly state = signal<AdminSession | null>(this.restore());
|
|
|
|
|
|
|
|
constructor() {
|
|
constructor() {
|
|
|
const onStorage = (event: StorageEvent) => {
|
|
const onStorage = (event: StorageEvent) => {
|
|
|
- if (event.storageArea !== localStorage || event.key !== ADMIN_SESSION_KEY) return;
|
|
|
|
|
- const previous = this.state();
|
|
|
|
|
- const next = this.decode(event.newValue);
|
|
|
|
|
- this.state.set(next);
|
|
|
|
|
- this.writeTabSession(next);
|
|
|
|
|
- if (previous && !next) this.redirectToLogin();
|
|
|
|
|
- else if (previous && next && previous.objectId !== next.objectId) void this.router.navigate(['/admin/dashboard']);
|
|
|
|
|
|
|
+ if (event.storageArea !== localStorage || (event.key !== ADMIN_SESSION_KEY && event.key !== null)) return;
|
|
|
|
|
+ // Storage events can be queued while another tab has already logged in again.
|
|
|
|
|
+ this.syncSharedSession();
|
|
|
};
|
|
};
|
|
|
window.addEventListener('storage', onStorage);
|
|
window.addEventListener('storage', onStorage);
|
|
|
inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
|
|
inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
|
|
@@ -59,8 +56,10 @@ export class AdminSessionService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
applyIdentity(identity: AdminIdentity): void {
|
|
applyIdentity(identity: AdminIdentity): void {
|
|
|
|
|
+ const previousToken = this.state()?.sessionToken;
|
|
|
|
|
+ this.syncSharedSession();
|
|
|
const current = this.state();
|
|
const current = this.state();
|
|
|
- if (!current || current.objectId !== identity.objectId) return;
|
|
|
|
|
|
|
+ if (!current || current.sessionToken !== previousToken || current.objectId !== identity.objectId) return;
|
|
|
const session: AdminSession = {
|
|
const session: AdminSession = {
|
|
|
...current,
|
|
...current,
|
|
|
displayName: identity.displayName,
|
|
displayName: identity.displayName,
|
|
@@ -91,6 +90,7 @@ export class AdminSessionService {
|
|
|
|
|
|
|
|
/** Confirm with Parse before turning one gateway failure into a global logout. */
|
|
/** Confirm with Parse before turning one gateway failure into a global logout. */
|
|
|
verifySessionAfterFailure(token: string): Promise<void> {
|
|
verifySessionAfterFailure(token: string): Promise<void> {
|
|
|
|
|
+ this.syncSharedSession();
|
|
|
if (this.state()?.sessionToken !== token) return Promise.resolve();
|
|
if (this.state()?.sessionToken !== token) return Promise.resolve();
|
|
|
const pending = this.sessionChecks.get(token);
|
|
const pending = this.sessionChecks.get(token);
|
|
|
if (pending) return pending;
|
|
if (pending) return pending;
|
|
@@ -104,15 +104,19 @@ export class AdminSessionService {
|
|
|
const user = await firstValueFrom(this.http.get<{ objectId: string; isDisabled?: boolean; isDeleted?: boolean }>(
|
|
const user = await firstValueFrom(this.http.get<{ objectId: string; isDisabled?: boolean; isDeleted?: boolean }>(
|
|
|
`${ADMIN_PARSE_URL}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': token } },
|
|
`${ADMIN_PARSE_URL}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': token } },
|
|
|
).pipe(timeout(10000)));
|
|
).pipe(timeout(10000)));
|
|
|
- if (this.state()?.sessionToken === token && (user.objectId !== this.state()?.objectId || user.isDisabled || user.isDeleted)) this.invalidateSession(token);
|
|
|
|
|
|
|
+ // A proxy may return an unexpected success body; only a real user is evidence.
|
|
|
|
|
+ if (typeof user?.objectId === 'string' && user.objectId && this.state()?.sessionToken === token &&
|
|
|
|
|
+ (user.objectId !== this.state()?.objectId || user.isDisabled === true || user.isDeleted === true)) this.invalidateSession(token);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- if (error instanceof HttpErrorResponse && (error.status === 401 || Number(error.error?.code) === 209)) this.invalidateSession(token);
|
|
|
|
|
- // Network errors and gateway outages are not evidence of an expired session.
|
|
|
|
|
|
|
+ if (error instanceof HttpErrorResponse && [400, 401].includes(error.status) && Number(error.error?.code) === 209) this.invalidateSession(token);
|
|
|
|
|
+ // An HTTP 401 alone can come from a proxy, not Parse's session validator.
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** A late response for an older login must never clear a newer session. */
|
|
/** A late response for an older login must never clear a newer session. */
|
|
|
invalidateSession(requestToken: string): void {
|
|
invalidateSession(requestToken: string): void {
|
|
|
|
|
+ // A newer login may already be stored even though its storage event has not run.
|
|
|
|
|
+ this.syncSharedSession();
|
|
|
if (!requestToken || this.state()?.sessionToken !== requestToken) return;
|
|
if (!requestToken || this.state()?.sessionToken !== requestToken) return;
|
|
|
this.persist(null);
|
|
this.persist(null);
|
|
|
this.state.set(null);
|
|
this.state.set(null);
|
|
@@ -133,6 +137,18 @@ export class AdminSessionService {
|
|
|
} catch { return null; }
|
|
} catch { return null; }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private syncSharedSession(): void {
|
|
|
|
|
+ if (!this.sharedStorageWritable) return;
|
|
|
|
|
+ let next: AdminSession | null;
|
|
|
|
|
+ try { next = this.decode(localStorage.getItem(ADMIN_SESSION_KEY)); }
|
|
|
|
|
+ catch { return; } // Keep the in-memory session when persistent storage is restricted.
|
|
|
|
|
+ const previous = this.state();
|
|
|
|
|
+ this.state.set(next);
|
|
|
|
|
+ this.writeTabSession(next);
|
|
|
|
|
+ if (previous && !next) this.redirectToLogin();
|
|
|
|
|
+ else if (previous && next && previous.objectId !== next.objectId) void this.router.navigate(['/admin/dashboard']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private writeTabSession(session: AdminSession | null): void {
|
|
private writeTabSession(session: AdminSession | null): void {
|
|
|
try {
|
|
try {
|
|
|
if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
@@ -144,7 +160,8 @@ export class AdminSessionService {
|
|
|
try {
|
|
try {
|
|
|
if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
|
else localStorage.removeItem(ADMIN_SESSION_KEY);
|
|
else localStorage.removeItem(ADMIN_SESSION_KEY);
|
|
|
- } catch { /* Fall back to the current tab when persistent storage is unavailable. */ }
|
|
|
|
|
|
|
+ this.sharedStorageWritable = true;
|
|
|
|
|
+ } catch { this.sharedStorageWritable = false; /* Fall back to the current tab. */ }
|
|
|
this.writeTabSession(session);
|
|
this.writeTabSession(session);
|
|
|
}
|
|
}
|
|
|
|
|
|