feat(auth): add session expired banner when token refresh fails

Users now see an amber banner with a re-login button instead of a
broken empty page when their session expires. Uses pub/sub events
from tokenManager, integrated in todo, calendar, zitare, contacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 21:24:28 +01:00
parent 02db49175a
commit d6440664ac
9 changed files with 376 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import type {
import { TokenState as TokenStateEnum } from '../types';
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
import type { AuthService } from './authService';
import { emitSessionExpired } from '../events/sessionExpired';
/**
* Configuration for the token manager
@ -110,6 +111,7 @@ export function createTokenManager(authService: AuthService, config?: TokenManag
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
emitSessionExpired();
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}

View file

@ -0,0 +1,68 @@
/**
* Session expired event system
*
* Provides a simple pub/sub mechanism for notifying UI layers
* when a user's session has permanently expired (token refresh failed).
*
* This is intentionally kept framework-agnostic so it can be consumed
* by Svelte, React, or plain JS consumers.
*/
type SessionExpiredListener = () => void;
const listeners = new Set<SessionExpiredListener>();
let _sessionExpired = false;
/**
* Subscribe to session expired events.
* Returns an unsubscribe function.
*/
export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener);
// If session is already expired, notify immediately
if (_sessionExpired) {
try {
listener();
} catch {
// Ignore listener errors
}
}
return () => {
listeners.delete(listener);
};
}
/**
* Emit a session expired event.
* Called internally by the token manager when refresh fails permanently.
*/
export function emitSessionExpired(): void {
if (_sessionExpired) return; // Only emit once
_sessionExpired = true;
listeners.forEach((listener) => {
try {
listener();
} catch {
// Ignore listener errors
}
});
}
/**
* Reset the session expired state.
* Should be called when the user logs in again.
*/
export function resetSessionExpired(): void {
_sessionExpired = false;
}
/**
* Check if the session is currently marked as expired.
*/
export function isSessionExpired(): boolean {
return _sessionExpired;
}

View file

@ -74,6 +74,16 @@ export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
export { ContactsClient, createContactsClient } from './clients/contactsClient';
export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient';
// Session expired events
import { resetSessionExpired as _resetSessionExpired } from './events/sessionExpired';
import { TokenState as _TokenStateEnum } from './types';
export {
onSessionExpired,
emitSessionExpired,
resetSessionExpired,
isSessionExpired,
} from './events/sessionExpired';
/**
* Initialize auth service with all adapters for web
*
@ -112,5 +122,12 @@ export function initializeWebAuth(config: {
if (config.backendUrl) urls.push(config.backendUrl);
_setupFetchInterceptor(authService, tokenManager, { urls });
// Reset session expired state when token becomes valid again (e.g., after re-login)
tokenManager.subscribe((state) => {
if (state === _TokenStateEnum.VALID) {
_resetSessionExpired();
}
});
return { authService, tokenManager };
}