diff --git a/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts b/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts index a07e421d3..0885b26c8 100644 --- a/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts +++ b/apps/shell-ui/src/auth/ApiKeyAuthProvider.ts @@ -26,7 +26,7 @@ // // Simple auth provider for open-source / local server mode. // User enters an API key (or leaves blank for open access), which is stored -// in sessionStorage and passed to ConnectionManager.connect(). +// in localStorage and passed to ConnectionManager.connect(). // // Implements the same IAuthProvider interface as CloudAuthProvider so the // ConnectionManager can use either interchangeably. @@ -63,7 +63,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { /** * Sign in with an API key. * - * Stores the key in sessionStorage for the current session. An empty string + * Stores the key in localStorage. An empty string * is valid (some OSS servers allow unauthenticated access). * * @param apiKey - The API key to store. @@ -84,7 +84,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ private async storeToken(token: string): Promise { try { - sessionStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[ApiKeyAuthProvider] Failed to store token:', e); } @@ -97,7 +97,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async getToken(): Promise { try { - const token = sessionStorage.getItem(LS_TOKEN); + const token = localStorage.getItem(LS_TOKEN); // For API key mode, empty string is valid (open access) return token; } catch { @@ -111,7 +111,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async isSignedIn(): Promise { try { - return sessionStorage.getItem(LS_TOKEN) !== null; + return localStorage.getItem(LS_TOKEN) !== null; } catch { return false; } @@ -126,7 +126,7 @@ export class ApiKeyAuthProvider implements IAuthProvider { */ public async signOut(): Promise { try { - sessionStorage.removeItem(LS_TOKEN); + localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[ApiKeyAuthProvider] Failed to clear token:', e); } diff --git a/apps/shell-ui/src/auth/CloudAuthProvider.ts b/apps/shell-ui/src/auth/CloudAuthProvider.ts index 06b093257..905387b10 100644 --- a/apps/shell-ui/src/auth/CloudAuthProvider.ts +++ b/apps/shell-ui/src/auth/CloudAuthProvider.ts @@ -31,7 +31,7 @@ // 2. Full-page redirect to Zitadel authorize endpoint // 3. Browser redirects back with ?code= parameter // 4. handleCallback() exchanges code for token via the ConnectionManager -// 5. Token stored in sessionStorage (browser equivalent of SecretStorage) +// 5. Token stored in localStorage (browser equivalent of SecretStorage) // // The stored token is picked up by ConnectionManager.connect() — auth is // decoupled from connection, same as VSCode. @@ -173,7 +173,7 @@ export class CloudAuthProvider implements IAuthProvider { } // ========================================================================= - // TOKEN STORAGE (sessionStorage — browser equivalent of SecretStorage) + // TOKEN STORAGE (localStorage — browser equivalent of SecretStorage) // ========================================================================= /** @@ -183,7 +183,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async storeToken(token: string): Promise { try { - sessionStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[CloudAuthProvider] Failed to store token:', e); } @@ -196,7 +196,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async getToken(): Promise { try { - const token = sessionStorage.getItem(LS_TOKEN); + const token = localStorage.getItem(LS_TOKEN); return token || null; } catch { return null; @@ -220,7 +220,7 @@ export class CloudAuthProvider implements IAuthProvider { */ public async signOut(): Promise { try { - sessionStorage.removeItem(LS_TOKEN); + localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[CloudAuthProvider] Failed to clear token:', e); } diff --git a/apps/shell-ui/src/connection/connection.ts b/apps/shell-ui/src/connection/connection.ts index c979d0792..21cbb1f1e 100644 --- a/apps/shell-ui/src/connection/connection.ts +++ b/apps/shell-ui/src/connection/connection.ts @@ -47,6 +47,7 @@ import type { ConnectionMode } from 'shared'; import { BaseManager } from './base-manager'; import { RemoteManager } from './remote-manager'; import { ConnectionFailure, withTimeout } from './errors'; +import { shouldReloadForTokenStorageUpdate } from './tokenStorageUpdate'; import { getStoredVerifier, clearStoredVerifier } from '../util/pkce'; import { LS_TOKEN, @@ -315,6 +316,33 @@ export class ConnectionManager implements IConnectionManager { // click would hit the guard and do nothing. Release it on bfcache restore // so the button works again without a manual page refresh. if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key !== LS_TOKEN) return; + + if (event.newValue === null) { + this.accountInfo = undefined; + this.pendingEvents.clear(); + this.clearServicesCache(); + this.updateConnectionStatus({ + state: ConnectionState.DISCONNECTED, + hasCredentials: false, + lastError: undefined, + progressMessage: undefined, + }); + window.location.reload(); + return; + } + + if (shouldReloadForTokenStorageUpdate({ + oldValue: event.oldValue, + newValue: event.newValue, + currentUserToken: this.accountInfo?.userToken, + hasAccountInfo: Boolean(this.accountInfo), + })) { + window.location.reload(); + } + }); + window.addEventListener('pageshow', (e) => { if ((e as PageTransitionEvent).persisted) { this.oauthStarted = false; @@ -839,21 +867,31 @@ export class ConnectionManager implements IConnectionManager { // TOKEN STORAGE // ========================================================================= - /** Persist a user token to sessionStorage. */ + /** Persist a user token to localStorage. */ public saveToken(token: string): void { - try { sessionStorage.setItem(LS_TOKEN, token); } catch (e) { + try { localStorage.setItem(LS_TOKEN, token); } catch (e) { console.error('[ConnectionManager] Failed to save token:', e); } } - /** Load token from sessionStorage. Returns empty string if unavailable. */ + /** Load token from localStorage. Migrates the old sessionStorage value once. */ public loadToken(): string { - try { return sessionStorage.getItem(LS_TOKEN) ?? ''; } catch { return ''; } + try { + const token = localStorage.getItem(LS_TOKEN); + if (token !== null) return token; + + const sessionToken = sessionStorage.getItem(LS_TOKEN); + if (sessionToken === null) return ''; + + localStorage.setItem(LS_TOKEN, sessionToken); + sessionStorage.removeItem(LS_TOKEN); + return sessionToken; + } catch { return ''; } } /** Clear the persisted token. */ public clearToken(): void { - try { sessionStorage.removeItem(LS_TOKEN); } catch (e) { + try { localStorage.removeItem(LS_TOKEN); } catch (e) { console.error('[ConnectionManager] Failed to clear token:', e); } } diff --git a/apps/shell-ui/src/connection/tokenStorageUpdate.ts b/apps/shell-ui/src/connection/tokenStorageUpdate.ts new file mode 100644 index 000000000..bedc13c75 --- /dev/null +++ b/apps/shell-ui/src/connection/tokenStorageUpdate.ts @@ -0,0 +1,18 @@ +export interface TokenStorageUpdate { + oldValue: string | null; + newValue: string | null; + currentUserToken?: string; + hasAccountInfo: boolean; +} + +export function shouldReloadForTokenStorageUpdate(update: TokenStorageUpdate): boolean { + const { oldValue, newValue, currentUserToken, hasAccountInfo } = update; + + if (newValue === null) return false; + + if (oldValue !== null) { + return currentUserToken !== newValue; + } + + return !hasAccountInfo; +} diff --git a/apps/shell-ui/src/constants.ts b/apps/shell-ui/src/constants.ts index e87473bac..31835df7a 100644 --- a/apps/shell-ui/src/constants.ts +++ b/apps/shell-ui/src/constants.ts @@ -28,7 +28,7 @@ // STORAGE KEYS // ============================================================================= -/** sessionStorage: auth token — survives refresh, cleared on tab close. */ +/** localStorage: auth token — persists across tabs, windows, and browser restarts. */ export const LS_TOKEN = 'rr:user_token'; /**