Compare commits

...

5 Commits

Author SHA1 Message Date
openhands
ac239db4e1 fix: ensure logout completes before navigating to logout page in SaaS mode 2025-03-23 20:31:11 +00:00
openhands
97cb1103a5 fix: auto-login when clicking logo on logout page in SaaS mode 2025-03-23 20:17:34 +00:00
openhands
129399bb50 feat: auto-login on session timeout in SaaS mode 2025-03-23 19:27:16 +00:00
Chuck Butkus
74e140a3a2 Implement last page redirection after login 2025-03-23 14:54:46 -04:00
Chuck Butkus
01a3e98bb9 Add logout page without sidebar 2025-03-23 14:54:45 -04:00
14 changed files with 323 additions and 14 deletions

View File

@@ -1,7 +1,20 @@
import axios from "axios";
import { saveLastPage } from "#/utils/last-page";
export const openHands = axios.create();
// Add response interceptor to handle 401 errors
openHands.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Save the last page before redirecting
saveLastPage();
}
return Promise.reject(error);
}
);
export const setAuthTokenHeader = (token: string) => {
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
};

View File

@@ -1,8 +1,7 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { NavLink, useLocation } from "react-router";
import { NavLink, useLocation, useNavigate } from "react-router";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
@@ -17,14 +16,15 @@ import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { saveLastPage } from "#/utils/last-page";
import { useAppLogout } from "#/hooks/use-app-logout";
export function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
@@ -35,8 +35,6 @@ export function Sidebar() {
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { mutateAsync: logout } = useLogout();
const { mutate: saveUserSettings } = useSaveSettings();
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
@@ -77,10 +75,12 @@ export function Sidebar() {
endSession();
};
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else saveUserSettings({ unset_github_token: true });
posthog.reset();
const { handleLogout: appLogout } = useAppLogout();
const handleLogout = () => {
// Save the current page before logout
saveLastPage();
appLogout();
};
return (

View File

@@ -3,6 +3,7 @@ import React from "react";
interface AuthContextType {
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
logout: () => void;
}
interface AuthContextProps extends React.PropsWithChildren {
@@ -16,12 +17,22 @@ function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
!!initialGithubTokenIsSet,
);
const logout = React.useCallback(() => {
setGitHubTokenIsSet(false);
// Save the last page before logging out
const { saveLastPage } = require('../utils/last-page');
saveLastPage();
// Clear any auth-related data from localStorage
localStorage.removeItem("gh_token");
}, [setGitHubTokenIsSet]);
const value = React.useMemo(
() => ({
githubTokenIsSet,
setGitHubTokenIsSet,
logout,
}),
[githubTokenIsSet, setGitHubTokenIsSet],
[githubTokenIsSet, setGitHubTokenIsSet, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -1,15 +1,42 @@
import { useLogout } from "./mutation/use-logout";
import { useSaveSettings } from "./mutation/use-save-settings";
import { useConfig } from "./query/use-config";
import { useNavigate } from "react-router";
import { useAuth } from "#/context/auth-context";
import posthog from "posthog-js";
export const useAppLogout = () => {
const { data: config } = useConfig();
const { mutateAsync: logout } = useLogout();
const { mutate: saveUserSettings } = useSaveSettings();
const { setGitHubTokenIsSet } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else saveUserSettings({ unset_github_token: true });
try {
if (config?.APP_MODE === "saas") {
// First perform the logout
await logout();
// Then clear local state
setGitHubTokenIsSet(false);
localStorage.removeItem("gh_token");
posthog.reset();
// Finally navigate to logout page
navigate("/logout");
} else {
saveUserSettings({ unset_github_token: true });
setGitHubTokenIsSet(false);
localStorage.removeItem("gh_token");
posthog.reset();
navigate("/");
}
} catch (error) {
console.error("Logout failed:", error);
// Still navigate to logout page in case of error
if (config?.APP_MODE === "saas") {
navigate("/logout");
}
}
};
return { handleLogout };

View File

@@ -6,6 +6,7 @@ import {
setUrl,
} from "#/state/browser-slice";
import { clearSelectedRepository } from "#/state/initial-query-slice";
import { saveLastPage } from "#/utils/last-page";
export const useEndSession = () => {
const navigate = useNavigate();
@@ -15,6 +16,9 @@ export const useEndSession = () => {
* End the current session by clearing the token and redirecting to the home page.
*/
const endSession = () => {
// Save the last page before ending session
//saveLastPage();
dispatch(clearSelectedRepository());
// Reset browser state to initial values

View File

@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { getLastPage, clearLastPage } from '../utils/last-page';
export const usePostLoginRedirect = (isLoggedIn: boolean) => {
const navigate = useNavigate();
useEffect(() => {
if (isLoggedIn) {
const lastPage = getLastPage();
if (lastPage) {
navigate(lastPage);
clearLastPage();
}
}
}, [isLoggedIn, navigate]);
};

View File

@@ -1,5 +1,10 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
AUTH$TRY_AGAIN = "AUTH$TRY_AGAIN",
AUTH$LOGOUT_ERROR = "AUTH$LOGOUT_ERROR",
AUTH$LOGGING_OUT = "AUTH$LOGGING_OUT",
AUTH$LOGGED_OUT = "AUTH$LOGGED_OUT",
AUTH$LOG_IN_WITH_GITHUB = "AUTH$LOG_IN_WITH_GITHUB",
APP$TITLE = "APP$TITLE",
BROWSER$TITLE = "BROWSER$TITLE",
BROWSER$EMPTY_MESSAGE = "BROWSER$EMPTY_MESSAGE",

View File

@@ -1,4 +1,79 @@
{
"AUTH$TRY_AGAIN": {
"en": "Try Again",
"ja": "再試行",
"zh-CN": "重试",
"zh-TW": "重試",
"ko-KR": "다시 시도",
"no": "Prøv igjen",
"it": "Riprova",
"pt": "Tentar novamente",
"es": "Intentar de nuevo",
"ar": "حاول مرة أخرى",
"fr": "Réessayer",
"tr": "Tekrar dene",
"de": "Erneut versuchen"
},
"AUTH$LOGOUT_ERROR": {
"en": "An error occurred while logging out. Please try again.",
"ja": "ログアウト中にエラーが発生しました。もう一度お試しください。",
"zh-CN": "退出登录时发生错误。请重试。",
"zh-TW": "登出時發生錯誤。請重試。",
"ko-KR": "로그아웃 중 오류가 발생했습니다. 다시 시도해 주세요.",
"no": "Det oppstod en feil under utlogging. Vennligst prøv igjen.",
"it": "Si è verificato un errore durante la disconnessione. Per favore riprova.",
"pt": "Ocorreu um erro ao sair. Por favor, tente novamente.",
"es": "Ocurrió un error al cerrar sesión. Por favor, inténtelo de nuevo.",
"ar": "حدث خطأ أثناء تسجيل الخروج. يرجى المحاولة مرة أخرى.",
"fr": "Une erreur s'est produite lors de la déconnexion. Veuillez réessayer.",
"tr": "Oturum kapatılırken bir hata oluştu. Lütfen tekrar deneyin.",
"de": "Beim Abmelden ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
},
"AUTH$LOGGING_OUT": {
"en": "Logging out...",
"ja": "ログアウト中...",
"zh-CN": "正在退出登录...",
"zh-TW": "正在登出...",
"ko-KR": "로그아웃 중...",
"no": "Logger ut...",
"it": "Disconnessione in corso...",
"pt": "Saindo...",
"es": "Cerrando sesión...",
"ar": "جاري تسجيل الخروج...",
"fr": "Déconnexion en cours...",
"tr": "Oturum kapatılıyor...",
"de": "Abmeldung läuft..."
},
"AUTH$LOGGED_OUT": {
"en": "You have been logged out",
"ja": "ログアウトしました",
"zh-CN": "您已退出登录",
"zh-TW": "您已登出",
"ko-KR": "로그아웃되었습니다",
"no": "Du har blitt logget ut",
"it": "Sei stato disconnesso",
"pt": "Você foi desconectado",
"es": "Has cerrado sesión",
"ar": "لقد تم تسجيل خروجك",
"fr": "Vous avez été déconnecté",
"tr": "Oturumunuz kapatıldı",
"de": "Sie wurden abgemeldet"
},
"AUTH$LOG_IN_WITH_GITHUB": {
"en": "Log in with GitHub",
"ja": "GitHubでログイン",
"zh-CN": "使用GitHub登录",
"zh-TW": "使用GitHub登入",
"ko-KR": "GitHub로 로그인",
"no": "Logg inn med GitHub",
"it": "Accedi con GitHub",
"pt": "Entrar com GitHub",
"es": "Iniciar sesión con GitHub",
"ar": "تسجيل الدخول باستخدام GitHub",
"fr": "Se connecter avec GitHub",
"tr": "GitHub ile giriş yap",
"de": "Mit GitHub anmelden"
},
"APP$TITLE": {
"en": "App",
"ja": "アプリ",

View File

@@ -6,6 +6,9 @@ import {
} from "@react-router/dev/routes";
export default [
layout("routes/_no-sidebar/route.tsx", [
route("logout", "routes/logout.tsx"),
]),
layout("routes/_oh/route.tsx", [
index("routes/_oh._index/route.tsx"),
route("settings", "routes/settings.tsx", [

View File

@@ -0,0 +1,10 @@
import React from "react";
import { Outlet } from "react-router";
export default function NoSidebarLayout() {
return (
<div className="h-screen w-screen">
<Outlet />
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { AgentState } from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { useEndSession } from "../../../hooks/use-end-session";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { saveLastPage } from "#/utils/last-page";
interface ServerError {
error: boolean | string;
@@ -32,6 +33,8 @@ export const useHandleWSEvents = () => {
if (isServerError(event)) {
if (event.error_code === 401) {
// Save the last page before ending session
saveLastPage();
displayErrorToast("Session expired.");
endSession();
return;

View File

@@ -21,6 +21,7 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useBalance } from "#/hooks/query/use-balance";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { usePostLoginRedirect } from "#/hooks/use-post-login-redirect";
export function ErrorBoundary() {
const error = useRouteError();
@@ -112,8 +113,27 @@ export default function MainApp() {
}, [error?.status, pathname, isFetching]);
const userIsAuthed = !!isAuthed && !authError;
const isOnLogoutPage = pathname === "/logout";
// In SaaS mode, when not authenticated and not on logout page, redirect to GitHub auth
React.useEffect(() => {
if (
!isFetchingAuth &&
!userIsAuthed &&
config.data?.APP_MODE === "saas" &&
!isOnLogoutPage &&
gitHubAuthUrl
) {
window.location.href = gitHubAuthUrl;
}
}, [isFetchingAuth, userIsAuthed, config.data?.APP_MODE, isOnLogoutPage, gitHubAuthUrl]);
// Only show waitlist modal in non-SaaS mode
const renderWaitlistModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE !== "saas";
// Handle redirection to last page after login
usePostLoginRedirect(userIsAuthed);
return (
<div

View File

@@ -0,0 +1,104 @@
import React from "react";
import { useNavigate } from "react-router";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import OpenHands from "#/api/open-hands";
import { useConfig } from "#/hooks/query/use-config";
// Hardcoded translations since we don't want to load i18n
const translations = {
LOGGING_OUT: "Logging out...",
LOGGED_OUT: "You have been logged out",
LOG_IN_WITH_GITHUB: "Log in with GitHub",
LOGOUT_ERROR: "An error occurred while logging out. Please try again.",
TRY_AGAIN: "Try Again",
};
export default function LogoutPage() {
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = React.useState(true);
const [hasLogoutError, setHasLogoutError] = React.useState(false);
const hasAttemptedLogout = React.useRef(false);
const config = useConfig();
// Generate GitHub auth URL once on mount
const gitHubAuthUrl = React.useMemo(
() => generateGitHubAuthUrl("github", new URL(window.location.href)),
[],
);
const handleLogoClick = React.useCallback(() => {
// In SaaS mode, redirect directly to GitHub auth
if (config.data?.APP_MODE === "saas" && gitHubAuthUrl) {
window.location.href = gitHubAuthUrl;
} else {
navigate("/");
}
}, [config.data?.APP_MODE, gitHubAuthUrl, navigate]);
const performLogout = React.useCallback(async () => {
// Only attempt logout once
if (hasAttemptedLogout.current) return;
hasAttemptedLogout.current = true;
try {
// Use the OpenHands API client for consistent headers and error handling
await OpenHands.logout();
// Clear any auth-related data from localStorage
localStorage.removeItem("gh_token");
setIsLoggingOut(false);
} catch (error) {
console.error('Logout error:', error);
setHasLogoutError(true);
setIsLoggingOut(false);
}
}, []);
React.useEffect(() => {
performLogout();
}, [performLogout]);
return (
<div className="h-screen w-screen flex items-center justify-center bg-base">
<div className="flex flex-col items-center gap-8 p-8 rounded-lg bg-neutral-800">
<AllHandsLogoButton onClick={handleLogoClick} />
{isLoggingOut ? (
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size="large" />
<span className="text-neutral-200">{translations.LOGGING_OUT}</span>
</div>
) : (
<>
<h1 className="text-2xl font-bold text-neutral-200">
{hasLogoutError
? translations.LOGOUT_ERROR
: translations.LOGGED_OUT}
</h1>
<div className="flex flex-col gap-4">
{hasLogoutError && (
<button
type="button"
onClick={performLogout}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 text-center"
>
{translations.TRY_AGAIN}
</button>
)}
{!hasLogoutError && gitHubAuthUrl && (
<a
href={gitHubAuthUrl}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 text-center"
>
{translations.LOG_IN_WITH_GITHUB}
</a>
)}
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
const LAST_PAGE_KEY = 'openhands_last_page';
export const saveLastPage = () => {
const currentPath = window.location.pathname;
// Don't save login, logout, or settings pages
if (!currentPath.includes('/settings') && currentPath !== '/' && currentPath !== '/logout') {
localStorage.setItem(LAST_PAGE_KEY, currentPath);
}
};
export const getLastPage = (): string | null => {
return localStorage.getItem(LAST_PAGE_KEY);
};
export const clearLastPage = () => {
localStorage.removeItem(LAST_PAGE_KEY);
};