feat(auth): detect session expiry live and offer re-login in place
Every response already funnels through runtimeFetch, so a classifier there spots 401s, confirms them against /auth/session (a proxied provider 401 must not read as a logout), and flips a small auth-session store. The web and hosted surfaces show a frosted banner under the header whose Log in button hands off to the session gate's existing unlock flow; sends are paused while expired, the session-load error screen explains the auth case and retries itself after login, and returning to a long-idle window revalidates once via visibility/focus. Native mobile feeds the same signal into its connection re-probe instead of showing the banner; VS Code is exempt.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
|
||||
/**
|
||||
* Non-blocking notice that the OpenChamber session expired mid-work. It never
|
||||
* takes the screen on its own: work stays visible and interactive, and only
|
||||
* the explicit "Log in" click hands control to the session gate's full login
|
||||
* flow (password, passkey, desktop shell — all already there).
|
||||
*/
|
||||
export const AuthExpiredBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const authState = useAuthSessionStore((store) => store.state);
|
||||
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
|
||||
|
||||
if (authState !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// Below the header on purpose: the header row can be a window-drag region
|
||||
// on desktop, where nothing under the cursor is clickable.
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
|
||||
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
|
||||
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
|
||||
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { AuthExpiredBanner } from './AuthExpiredBanner';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
@@ -557,6 +559,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
// Mid-session expiry: the banner asks for a re-login by flipping the shared
|
||||
// auth store to 'reauthenticating'; the gate answers with its own status
|
||||
// check, which lands in the full 'locked' flow on a genuine 401. A
|
||||
// successful login resolves the store back to 'ok'.
|
||||
const authSessionState = useAuthSessionStore((store) => store.state);
|
||||
React.useEffect(() => {
|
||||
if (!skipAuth) installAuthSessionFocusWatch();
|
||||
}, [skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (authSessionState === 'reauthenticating') {
|
||||
void checkStatusRef.current?.();
|
||||
}
|
||||
}, [authSessionState, skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state === 'locked' && passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
@@ -983,5 +1006,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{skipAuth ? null : <AuthExpiredBanner />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user