Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/shell-ui/src/auth/ApiKeyAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -84,7 +84,7 @@ export class ApiKeyAuthProvider implements IAuthProvider {
*/
private async storeToken(token: string): Promise<void> {
try {
sessionStorage.setItem(LS_TOKEN, token);
localStorage.setItem(LS_TOKEN, token);
} catch (e) {
console.error('[ApiKeyAuthProvider] Failed to store token:', e);
}
Expand All @@ -97,7 +97,7 @@ export class ApiKeyAuthProvider implements IAuthProvider {
*/
public async getToken(): Promise<string | null> {
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 {
Expand All @@ -111,7 +111,7 @@ export class ApiKeyAuthProvider implements IAuthProvider {
*/
public async isSignedIn(): Promise<boolean> {
try {
return sessionStorage.getItem(LS_TOKEN) !== null;
return localStorage.getItem(LS_TOKEN) !== null;
} catch {
return false;
}
Expand All @@ -126,7 +126,7 @@ export class ApiKeyAuthProvider implements IAuthProvider {
*/
public async signOut(): Promise<void> {
try {
sessionStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_TOKEN);
} catch (e) {
console.error('[ApiKeyAuthProvider] Failed to clear token:', e);
}
Expand Down
10 changes: 5 additions & 5 deletions apps/shell-ui/src/auth/CloudAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -173,7 +173,7 @@ export class CloudAuthProvider implements IAuthProvider {
}

// =========================================================================
// TOKEN STORAGE (sessionStorage — browser equivalent of SecretStorage)
// TOKEN STORAGE (localStorage — browser equivalent of SecretStorage)
// =========================================================================

/**
Expand All @@ -183,7 +183,7 @@ export class CloudAuthProvider implements IAuthProvider {
*/
public async storeToken(token: string): Promise<void> {
try {
sessionStorage.setItem(LS_TOKEN, token);
localStorage.setItem(LS_TOKEN, token);
} catch (e) {
console.error('[CloudAuthProvider] Failed to store token:', e);
}
Expand All @@ -196,7 +196,7 @@ export class CloudAuthProvider implements IAuthProvider {
*/
public async getToken(): Promise<string | null> {
try {
const token = sessionStorage.getItem(LS_TOKEN);
const token = localStorage.getItem(LS_TOKEN);
return token || null;
} catch {
return null;
Expand All @@ -220,7 +220,7 @@ export class CloudAuthProvider implements IAuthProvider {
*/
public async signOut(): Promise<void> {
try {
sessionStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_TOKEN);
} catch (e) {
console.error('[CloudAuthProvider] Failed to clear token:', e);
}
Expand Down
102 changes: 102 additions & 0 deletions apps/shell-ui/src/components/layout/ConnectionErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { CSSProperties, useEffect, useState } from 'react';
import { ConnectionState } from 'shared';
import { ConnectionManager } from '../../connection/connection';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';

const styles = {
banner: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
minHeight: 36,
padding: '6px 12px',
backgroundColor: 'var(--rr-bg-paper)',
borderBottom: '1px solid var(--rr-border)',
color: 'var(--rr-text-primary)',
fontFamily: 'var(--rr-font-family)',
fontSize: 'var(--rr-font-size-widget)',
} as CSSProperties,
message: { flex: '0 1 auto' } as CSSProperties,
action: {
border: 0,
background: 'none',
padding: 0,
color: 'var(--rr-brand)',
font: 'inherit',
fontWeight: 600,
cursor: 'pointer',
} as CSSProperties,
dismiss: {
position: 'absolute',
right: 12,
border: 0,
background: 'none',
padding: '0 4px',
color: 'var(--rr-text-secondary)',
fontSize: 20,
lineHeight: 1,
cursor: 'pointer',
} as CSSProperties,
};

export interface ConnectionErrorBannerProps {
/** Override the status-derived failure message for other connection errors. */
message?: string;
/** Override retry handling; defaults to ConnectionManager.reconnect(). */
onRetry?: () => void | Promise<void>;
/** Override sign-in handling; defaults to ConnectionManager.startOAuth(false). */
onSignIn?: () => void | Promise<void>;
}

/** A dismissible recovery banner for authentication and transport failures. */
export const ConnectionErrorBanner: React.FC<ConnectionErrorBannerProps> = ({ message, onRetry, onSignIn }) => {
const status = useConnectionStatus();
const [dismissed, setDismissed] = useState(false);
// The failure is latched manager-side (status.lastFailure) so reconnect
// attempts and anonymous connects can't erase it before it renders.
const failure = status.lastFailure;

useEffect(() => {
setDismissed(false);
}, [failure?.state, failure?.lastError]);

if (dismissed || !failure) return null;

const isNetworkFailure = failure.state === ConnectionState.NETWORK_ERROR;
const defaultMessage = isNetworkFailure
? 'Can\'t reach the server — check your connection and retry.'
: 'Your session has expired — please sign in again.';
const action = isNetworkFailure ? 'Retry' : 'Sign in';
const onAction = isNetworkFailure
? onRetry ?? (() => {
const connection = ConnectionManager.getInstance();
if (!connection.hasAttached()) {
window.location.reload();
return;
}
return connection.reconnect();
})
: onSignIn ?? (() => ConnectionManager.getInstance().startOAuth(false));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div style={styles.banner} role="alert">
<span style={styles.message} title={failure.lastError}>{message ?? defaultMessage}</span>
<button type="button" style={styles.action} onClick={() => {
void Promise.resolve().then(onAction).catch((error) => {
console.error('[ConnectionErrorBanner] Recovery action failed:', error);
});
}}>
{action}
</button>
<button type="button" style={styles.dismiss} aria-label="Dismiss connection error" onClick={() => setDismissed(true)}>
×
</button>
</div>
);
};
2 changes: 2 additions & 0 deletions apps/shell-ui/src/components/layout/ShellLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import Sidebar from './Sidebar';
import StatusBar from './StatusBar';
import LoadingScreen from './LoadingScreen';
import DebugPanel from './DebugPanel';
import { ConnectionErrorBanner } from './ConnectionErrorBanner';
import type { ShellConfig } from '../../workspace/types';
import { commonStyles } from 'shared/themes/styles';

Expand Down Expand Up @@ -283,6 +284,7 @@ export const ShellLayout: React.FC<ShellLayoutProps> = ({
<ShellApiConfigProvider config={mergedApiConfig}>
<OverlayManager>
<div style={styles.shell}>
<ConnectionErrorBanner />
{/* Main row: Sidebar | Client Area | Debug Panel */}
<div style={styles.main}>
{/* Sidebar zone */}
Expand Down
Loading
Loading