|
|
@@ -1,16 +1,31 @@
|
|
|
-import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
|
-import { computed, inject, Injectable, signal } from '@angular/core';
|
|
|
+import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
|
+import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
|
|
|
import { Router } from '@angular/router';
|
|
|
-import { firstValueFrom } from 'rxjs';
|
|
|
-import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID } from './admin.constants';
|
|
|
+import { firstValueFrom, timeout } from 'rxjs';
|
|
|
+import { ADMIN_PARSE_URL, ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID } from './admin.constants';
|
|
|
import { AdminIdentity, AdminSession } from './admin.models';
|
|
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
|
export class AdminSessionService {
|
|
|
private readonly http = inject(HttpClient);
|
|
|
private readonly router = inject(Router);
|
|
|
+ private readonly sessionChecks = new Map<string, Promise<void>>();
|
|
|
private readonly state = signal<AdminSession | null>(this.restore());
|
|
|
|
|
|
+ constructor() {
|
|
|
+ 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']);
|
|
|
+ };
|
|
|
+ window.addEventListener('storage', onStorage);
|
|
|
+ inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
|
|
|
+ }
|
|
|
+
|
|
|
readonly session = this.state.asReadonly();
|
|
|
readonly isAuthenticated = computed(() => Boolean(this.state()?.sessionToken));
|
|
|
|
|
|
@@ -36,14 +51,14 @@ export class AdminSessionService {
|
|
|
isSuperAdmin: result['adminRoleKey'] === 'super-admin' || (Array.isArray(result['roles']) && result['roles'].includes('super-admin')),
|
|
|
};
|
|
|
if (!session.objectId || !session.sessionToken) throw new Error('登录响应缺少有效会话');
|
|
|
- sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
|
+ this.persist(session);
|
|
|
this.state.set(session);
|
|
|
return session;
|
|
|
}
|
|
|
|
|
|
applyIdentity(identity: AdminIdentity): void {
|
|
|
const current = this.state();
|
|
|
- if (!current) return;
|
|
|
+ if (!current || current.objectId !== identity.objectId) return;
|
|
|
const session: AdminSession = {
|
|
|
...current,
|
|
|
displayName: identity.displayName,
|
|
|
@@ -52,13 +67,13 @@ export class AdminSessionService {
|
|
|
operationsRole: identity.operationsRole,
|
|
|
company: identity.company ?? current.company,
|
|
|
};
|
|
|
- sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
|
+ this.persist(session);
|
|
|
this.state.set(session);
|
|
|
}
|
|
|
|
|
|
logout(redirect = true): void {
|
|
|
const sessionToken = this.state()?.sessionToken ?? '';
|
|
|
- sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
|
+ this.persist(null);
|
|
|
this.state.set(null);
|
|
|
if (sessionToken) {
|
|
|
this.http.post(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`, {
|
|
|
@@ -66,18 +81,75 @@ export class AdminSessionService {
|
|
|
params: { operation: 'logout', sessionToken },
|
|
|
}).subscribe({ error: () => undefined });
|
|
|
}
|
|
|
- if (redirect) void this.router.navigate(['/admin/login']);
|
|
|
+ if (redirect) this.redirectToLogin();
|
|
|
}
|
|
|
|
|
|
- private restore(): AdminSession | null {
|
|
|
+ /** Confirm with Parse before turning one gateway failure into a global logout. */
|
|
|
+ verifySessionAfterFailure(token: string): Promise<void> {
|
|
|
+ if (this.state()?.sessionToken !== token) return Promise.resolve();
|
|
|
+ const pending = this.sessionChecks.get(token);
|
|
|
+ if (pending) return pending;
|
|
|
+ const check = this.checkSession(token).finally(() => this.sessionChecks.delete(token));
|
|
|
+ this.sessionChecks.set(token, check);
|
|
|
+ return check;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async checkSession(token: string): Promise<void> {
|
|
|
+ try {
|
|
|
+ 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 } },
|
|
|
+ ).pipe(timeout(10000)));
|
|
|
+ if (this.state()?.sessionToken === token && (user.objectId !== this.state()?.objectId || user.isDisabled || user.isDeleted)) this.invalidateSession(token);
|
|
|
+ } 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.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** A late response for an older login must never clear a newer session. */
|
|
|
+ invalidateSession(requestToken: string): void {
|
|
|
+ if (!requestToken || this.state()?.sessionToken !== requestToken) return;
|
|
|
+ this.persist(null);
|
|
|
+ this.state.set(null);
|
|
|
+ this.redirectToLogin();
|
|
|
+ }
|
|
|
+
|
|
|
+ private redirectToLogin(): void {
|
|
|
+ const returnUrl = this.router.url;
|
|
|
+ void this.router.navigate(['/admin/login'], {
|
|
|
+ queryParams: returnUrl?.startsWith('/admin/') && !returnUrl.startsWith('/admin/login') ? { returnUrl } : {},
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private decode(value: string | null): AdminSession | null {
|
|
|
try {
|
|
|
- const value = sessionStorage.getItem(ADMIN_SESSION_KEY);
|
|
|
- if (!value) return null;
|
|
|
- const session = JSON.parse(value) as AdminSession;
|
|
|
- return session.sessionToken ? session : null;
|
|
|
- } catch {
|
|
|
- sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
|
- return null;
|
|
|
+ const session = JSON.parse(value || 'null') as AdminSession | null;
|
|
|
+ return session && typeof session.sessionToken === 'string' && session.sessionToken && session.objectId ? session : null;
|
|
|
+ } catch { return null; }
|
|
|
+ }
|
|
|
+
|
|
|
+ private writeTabSession(session: AdminSession | null): void {
|
|
|
+ try {
|
|
|
+ if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
|
+ else sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
|
+ } catch { /* The current in-memory login remains usable if storage is unavailable. */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private persist(session: AdminSession | null): void {
|
|
|
+ try {
|
|
|
+ if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
|
|
|
+ else localStorage.removeItem(ADMIN_SESSION_KEY);
|
|
|
+ } catch { /* Fall back to the current tab when persistent storage is unavailable. */ }
|
|
|
+ this.writeTabSession(session);
|
|
|
+ }
|
|
|
+
|
|
|
+ private restore(): AdminSession | null {
|
|
|
+ let session: AdminSession | null = null;
|
|
|
+ try { session = this.decode(localStorage.getItem(ADMIN_SESSION_KEY)); } catch { /* restricted storage */ }
|
|
|
+ if (!session) {
|
|
|
+ try { session = this.decode(sessionStorage.getItem(ADMIN_SESSION_KEY)); } catch { /* restricted storage */ }
|
|
|
}
|
|
|
+ this.persist(session);
|
|
|
+ return session;
|
|
|
}
|
|
|
}
|