Skip to content

Commit 3280f45

Browse files
Update to login if session times out and return to previous conversation (All-Hands-AI#8587)
Co-authored-by: openhands <[email protected]>
1 parent 6335afb commit 3280f45

File tree

9 files changed

+281
-2
lines changed

9 files changed

+281
-2
lines changed

frontend/src/components/features/waitlist/auth-modal.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
99
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
1010
import { useAuthUrl } from "#/hooks/use-auth-url";
1111
import { GetConfigResponse } from "#/api/open-hands.types";
12+
import { LoginMethod, setLoginMethod } from "#/utils/local-storage";
1213

1314
interface AuthModalProps {
1415
githubAuthUrl: string | null;
@@ -25,13 +26,21 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
2526

2627
const handleGitHubAuth = () => {
2728
if (githubAuthUrl) {
29+
// Store the login method in local storage (only in SAAS mode)
30+
if (appMode === "saas") {
31+
setLoginMethod(LoginMethod.GITHUB);
32+
}
2833
// Always start the OIDC flow, let the backend handle TOS check
2934
window.location.href = githubAuthUrl;
3035
}
3136
};
3237

3338
const handleGitLabAuth = () => {
3439
if (gitlabAuthUrl) {
40+
// Store the login method in local storage (only in SAAS mode)
41+
if (appMode === "saas") {
42+
setLoginMethod(LoginMethod.GITLAB);
43+
}
3544
// Always start the OIDC flow, let the backend handle TOS check
3645
window.location.href = gitlabAuthUrl;
3746
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
4+
import { ModalBody } from "#/components/shared/modals/modal-body";
5+
import { I18nKey } from "#/i18n/declaration";
6+
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
7+
8+
export function ReauthModal() {
9+
const { t } = useTranslation();
10+
11+
return (
12+
<ModalBackdrop>
13+
<ModalBody className="border border-tertiary">
14+
<AllHandsLogo width={68} height={46} />
15+
<div className="flex flex-col gap-2 w-full items-center text-center">
16+
<h1 className="text-2xl font-bold">
17+
{t(I18nKey.AUTH$LOGGING_BACK_IN)}
18+
</h1>
19+
</div>
20+
</ModalBody>
21+
</ModalBackdrop>
22+
);
23+
}

frontend/src/hooks/mutation/use-logout.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router";
33
import posthog from "posthog-js";
44
import OpenHands from "#/api/open-hands";
55
import { useConfig } from "../query/use-config";
6+
import { clearLoginData } from "#/utils/local-storage";
67

78
export const useLogout = () => {
89
const queryClient = useQueryClient();
@@ -17,8 +18,16 @@ export const useLogout = () => {
1718
queryClient.removeQueries({ queryKey: ["user"] });
1819
queryClient.removeQueries({ queryKey: ["secrets"] });
1920

21+
// Clear login method and last page from local storage
22+
if (config?.APP_MODE === "saas") {
23+
clearLoginData();
24+
}
25+
2026
posthog.reset();
2127
await navigate("/");
28+
29+
// Refresh the page after all logout logic is completed
30+
window.location.reload();
2231
},
2332
});
2433
};

frontend/src/hooks/use-auto-login.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useEffect } from "react";
2+
import { useNavigate } from "react-router";
3+
import { useConfig } from "./query/use-config";
4+
import { useIsAuthed } from "./query/use-is-authed";
5+
import {
6+
getLoginMethod,
7+
getLastPage,
8+
LoginMethod,
9+
} from "#/utils/local-storage";
10+
import { useAuthUrl } from "./use-auth-url";
11+
12+
/**
13+
* Hook to automatically log in the user if they have a login method stored in local storage
14+
* Only works in SAAS mode and when the user is not already logged in
15+
*/
16+
export const useAutoLogin = () => {
17+
const navigate = useNavigate();
18+
const { data: config, isLoading: isConfigLoading } = useConfig();
19+
const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();
20+
21+
// Get the stored login method
22+
const loginMethod = getLoginMethod();
23+
24+
// Get the auth URLs for both providers
25+
const githubAuthUrl = useAuthUrl({
26+
appMode: config?.APP_MODE || null,
27+
identityProvider: "github",
28+
});
29+
30+
const gitlabAuthUrl = useAuthUrl({
31+
appMode: config?.APP_MODE || null,
32+
identityProvider: "gitlab",
33+
});
34+
35+
useEffect(() => {
36+
// Only auto-login in SAAS mode
37+
if (config?.APP_MODE !== "saas") {
38+
return;
39+
}
40+
41+
// Wait for auth and config to load
42+
if (isConfigLoading || isAuthLoading) {
43+
return;
44+
}
45+
46+
// Don't auto-login if already authenticated
47+
if (isAuthed) {
48+
return;
49+
}
50+
51+
// Don't auto-login if no login method is stored
52+
if (!loginMethod) {
53+
return;
54+
}
55+
56+
// Get the appropriate auth URL based on the stored login method
57+
const authUrl =
58+
loginMethod === LoginMethod.GITHUB ? githubAuthUrl : gitlabAuthUrl;
59+
60+
// If we have an auth URL, redirect to it
61+
if (authUrl) {
62+
// After successful login, the user will be redirected back and can navigate to the last page
63+
window.location.href = authUrl;
64+
}
65+
}, [
66+
config?.APP_MODE,
67+
isAuthed,
68+
isConfigLoading,
69+
isAuthLoading,
70+
loginMethod,
71+
githubAuthUrl,
72+
gitlabAuthUrl,
73+
]);
74+
75+
// Handle navigation to last page after login
76+
useEffect(() => {
77+
// Only navigate in SAAS mode
78+
if (config?.APP_MODE !== "saas") {
79+
return;
80+
}
81+
82+
// Wait for auth to load
83+
if (isAuthLoading) {
84+
return;
85+
}
86+
87+
// Only navigate if authenticated
88+
if (!isAuthed) {
89+
return;
90+
}
91+
92+
// Get the last page from local storage
93+
const lastPage = getLastPage();
94+
95+
// Navigate to the last page if it exists
96+
if (lastPage) {
97+
navigate(lastPage);
98+
}
99+
}, [config?.APP_MODE, isAuthed, isAuthLoading, navigate]);
100+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect } from "react";
2+
import { useLocation } from "react-router";
3+
import { useConfig } from "./query/use-config";
4+
import { setLastPage, shouldExcludePath } from "#/utils/local-storage";
5+
import { useIsAuthed } from "./query/use-is-authed";
6+
7+
/**
8+
* Hook to track the last visited page in local storage
9+
* Only tracks pages in SAAS mode and excludes certain paths
10+
*/
11+
export const useTrackLastPage = () => {
12+
const location = useLocation();
13+
const { data: config } = useConfig();
14+
const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();
15+
16+
useEffect(() => {
17+
// Only track pages in SAAS mode when authenticated
18+
if (config?.APP_MODE !== "saas" || !isAuthed || isAuthLoading) {
19+
return;
20+
}
21+
22+
const { pathname } = location;
23+
24+
// Don't track excluded paths
25+
if (shouldExcludePath(pathname)) {
26+
// leave code block for now as we may decide not to track certain pages.
27+
// return;
28+
}
29+
30+
// Store the current path as the last visited page
31+
setLastPage(pathname);
32+
}, [location, config?.APP_MODE]);
33+
};

frontend/src/i18n/declaration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// this file generate by script, don't modify it manually!!!
22
export enum I18nKey {
3+
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
34
SECURITY$LOW_RISK = "SECURITY$LOW_RISK",
45
SECURITY$MEDIUM_RISK = "SECURITY$MEDIUM_RISK",
56
SECURITY$HIGH_RISK = "SECURITY$HIGH_RISK",
@@ -12,7 +13,6 @@ export enum I18nKey {
1213
OBSERVATION$MCP_NO_OUTPUT = "OBSERVATION$MCP_NO_OUTPUT",
1314
OBSERVATION$ERROR_PREFIX = "OBSERVATION$ERROR_PREFIX",
1415
TASK$ADDRESSING_TASK = "TASK$ADDRESSING_TASK",
15-
CHAT_INTERFACE$AGENT_ERROR_MESSAGE = "CHAT_INTERFACE$AGENT_ERROR_MESSAGE",
1616
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
1717
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
1818
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
@@ -292,6 +292,7 @@ export enum I18nKey {
292292
CHAT_INTERFACE$AGENT_STOPPED_MESSAGE = "CHAT_INTERFACE$AGENT_STOPPED_MESSAGE",
293293
CHAT_INTERFACE$AGENT_FINISHED_MESSAGE = "CHAT_INTERFACE$AGENT_FINISHED_MESSAGE",
294294
CHAT_INTERFACE$AGENT_REJECTED_MESSAGE = "CHAT_INTERFACE$AGENT_REJECTED_MESSAGE",
295+
CHAT_INTERFACE$AGENT_ERROR_MESSAGE = "CHAT_INTERFACE$AGENT_ERROR_MESSAGE",
295296
CHAT_INTERFACE$AGENT_AWAITING_USER_CONFIRMATION_MESSAGE = "CHAT_INTERFACE$AGENT_AWAITING_USER_CONFIRMATION_MESSAGE",
296297
CHAT_INTERFACE$AGENT_ACTION_USER_CONFIRMED_MESSAGE = "CHAT_INTERFACE$AGENT_ACTION_USER_CONFIRMED_MESSAGE",
297298
CHAT_INTERFACE$AGENT_ACTION_USER_REJECTED_MESSAGE = "CHAT_INTERFACE$AGENT_ACTION_USER_REJECTED_MESSAGE",

frontend/src/i18n/translation.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
{
2+
"AUTH$LOGGING_BACK_IN": {
3+
"en": "Logging back into OpenHands...",
4+
"ja": "OpenHandsに再ログインしています...",
5+
"zh-CN": "正在重新登录OpenHands...",
6+
"zh-TW": "正在重新登錄OpenHands...",
7+
"ko-KR": "OpenHands에 다시 로그인 중...",
8+
"no": "Logger inn igjen på OpenHands...",
9+
"it": "Accesso nuovamente a OpenHands...",
10+
"pt": "Entrando novamente no OpenHands...",
11+
"es": "Iniciando sesión de nuevo en OpenHands...",
12+
"ar": "جاري إعادة تسجيل الدخول إلى OpenHands...",
13+
"fr": "Reconnexion à OpenHands...",
14+
"tr": "OpenHands'e yeniden giriş yapılıyor...",
15+
"de": "Erneute Anmeldung bei OpenHands...",
16+
"uk": "Повторний вхід до OpenHands..."
17+
},
218
"SECURITY$LOW_RISK": {
319
"en": "Low Risk",
420
"ja": "低リスク",

frontend/src/routes/root-layout.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
1414
import { useConfig } from "#/hooks/query/use-config";
1515
import { Sidebar } from "#/components/features/sidebar/sidebar";
1616
import { AuthModal } from "#/components/features/waitlist/auth-modal";
17+
import { ReauthModal } from "#/components/features/waitlist/reauth-modal";
1718
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
1819
import { useSettings } from "#/hooks/query/use-settings";
1920
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
2021
import { useBalance } from "#/hooks/query/use-balance";
2122
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
2223
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
2324
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
25+
import { useTrackLastPage } from "#/hooks/use-track-last-page";
26+
import { useAutoLogin } from "#/hooks/use-auto-login";
27+
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
2428

2529
export function ErrorBoundary() {
2630
const error = useRouteError();
@@ -82,6 +86,12 @@ export default function MainApp() {
8286

8387
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
8488

89+
// Track the last visited page
90+
useTrackLastPage();
91+
92+
// Auto-login if login method is stored in local storage
93+
useAutoLogin();
94+
8595
React.useEffect(() => {
8696
// Don't change language when on TOS page
8797
if (!isOnTosPage && settings?.LANGUAGE) {
@@ -125,12 +135,30 @@ export default function MainApp() {
125135
}
126136
}, [error?.status, pathname, isOnTosPage]);
127137

138+
// Check if login method exists in local storage
139+
const loginMethodExists = React.useMemo(() => {
140+
// Only check localStorage if we're in a browser environment
141+
if (typeof window !== "undefined" && window.localStorage) {
142+
return localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD) !== null;
143+
}
144+
return false;
145+
}, []);
146+
128147
const renderAuthModal =
129148
!isAuthed &&
130149
!isAuthError &&
131150
!isFetchingAuth &&
132151
!isOnTosPage &&
133-
config.data?.APP_MODE === "saas";
152+
config.data?.APP_MODE === "saas" &&
153+
!loginMethodExists; // Don't show auth modal if login method exists in local storage
154+
155+
const renderReAuthModal =
156+
!isAuthed &&
157+
!isAuthError &&
158+
!isFetchingAuth &&
159+
!isOnTosPage &&
160+
config.data?.APP_MODE === "saas" &&
161+
loginMethodExists;
134162

135163
return (
136164
<div
@@ -152,6 +180,7 @@ export default function MainApp() {
152180
appMode={config.data?.APP_MODE}
153181
/>
154182
)}
183+
{renderReAuthModal && <ReauthModal />}
155184
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
156185
<AnalyticsConsentFormModal
157186
onClose={() => {

frontend/src/utils/local-storage.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Local storage keys
2+
export const LOCAL_STORAGE_KEYS = {
3+
LOGIN_METHOD: "openhands_login_method",
4+
LAST_PAGE: "openhands_last_page",
5+
};
6+
7+
// Login methods
8+
export enum LoginMethod {
9+
GITHUB = "github",
10+
GITLAB = "gitlab",
11+
}
12+
13+
/**
14+
* Set the login method in local storage
15+
* @param method The login method (github or gitlab)
16+
*/
17+
export const setLoginMethod = (method: LoginMethod): void => {
18+
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method);
19+
};
20+
21+
/**
22+
* Get the login method from local storage
23+
* @returns The login method or null if not set
24+
*/
25+
export const getLoginMethod = (): LoginMethod | null => {
26+
const method = localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD);
27+
return method as LoginMethod | null;
28+
};
29+
30+
/**
31+
* Set the last visited page in local storage
32+
* @param path The path of the last visited page
33+
*/
34+
export const setLastPage = (path: string): void => {
35+
localStorage.setItem(LOCAL_STORAGE_KEYS.LAST_PAGE, path);
36+
};
37+
38+
/**
39+
* Get the last visited page from local storage
40+
* @returns The last visited page or null if not set
41+
*/
42+
export const getLastPage = (): string | null =>
43+
localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_PAGE);
44+
45+
/**
46+
* Clear login method and last page from local storage
47+
*/
48+
export const clearLoginData = (): void => {
49+
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD);
50+
localStorage.removeItem(LOCAL_STORAGE_KEYS.LAST_PAGE);
51+
};
52+
53+
/**
54+
* Check if the given path should be excluded from being saved as the last page
55+
* @param path The path to check
56+
* @returns True if the path should be excluded, false otherwise
57+
*/
58+
export const shouldExcludePath = (path: string): boolean =>
59+
path.startsWith("/settings");

0 commit comments

Comments
 (0)