fix(web): use BroadcastChannel to sync token refreshes across tabs
When multiple tabs are open and a token expires, each tab independently attempts a refresh. With server-side token rotation this causes all but the first tab to fail, logging the user out. Add a BroadcastChannel (memos_token_sync) so that when any tab successfully refreshes, it broadcasts the new token to all other tabs. Receiving tabs adopt the token in-memory immediately, skipping their own refresh request and avoiding conflicts with token rotation. Falls back gracefully when BroadcastChannel is unavailable (e.g. some privacy modes).
This commit is contained in:
parent
26d10212c6
commit
bbdc998646
1 changed files with 40 additions and 0 deletions
|
|
@ -6,6 +6,43 @@ let tokenExpiresAt: Date | null = null;
|
||||||
const TOKEN_KEY = "memos_access_token";
|
const TOKEN_KEY = "memos_access_token";
|
||||||
const EXPIRES_KEY = "memos_token_expires_at";
|
const EXPIRES_KEY = "memos_token_expires_at";
|
||||||
|
|
||||||
|
// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one
|
||||||
|
// tab needs to hit the refresh endpoint. When another tab successfully refreshes
|
||||||
|
// we adopt the new token immediately, avoiding a redundant (and potentially
|
||||||
|
// conflicting) refresh request of our own.
|
||||||
|
const TOKEN_CHANNEL_NAME = "memos_token_sync";
|
||||||
|
|
||||||
|
interface TokenBroadcastMessage {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string; // ISO string
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenChannel: BroadcastChannel | null = null;
|
||||||
|
|
||||||
|
function getTokenChannel(): BroadcastChannel | null {
|
||||||
|
if (tokenChannel) return tokenChannel;
|
||||||
|
try {
|
||||||
|
tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME);
|
||||||
|
tokenChannel.onmessage = (event: MessageEvent<TokenBroadcastMessage>) => {
|
||||||
|
const { token, expiresAt } = event.data ?? {};
|
||||||
|
if (token && expiresAt) {
|
||||||
|
// Another tab refreshed — adopt the token in-memory so we don't
|
||||||
|
// fire our own refresh request.
|
||||||
|
accessToken = token;
|
||||||
|
tokenExpiresAt = new Date(expiresAt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// BroadcastChannel not available (e.g. some privacy modes)
|
||||||
|
tokenChannel = null;
|
||||||
|
}
|
||||||
|
return tokenChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the channel at module load so the listener is registered
|
||||||
|
// before any token refresh can occur in any tab.
|
||||||
|
getTokenChannel();
|
||||||
|
|
||||||
export const getAccessToken = (): string | null => {
|
export const getAccessToken = (): string | null => {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,6 +78,9 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void =>
|
||||||
if (token && expiresAt) {
|
if (token && expiresAt) {
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
|
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
|
||||||
|
// Broadcast to other tabs so they adopt the new token without refreshing.
|
||||||
|
const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() };
|
||||||
|
getTokenChannel()?.postMessage(msg);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(EXPIRES_KEY);
|
localStorage.removeItem(EXPIRES_KEY);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue