Allow user to change their email address (#8861)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
chuckbutkus
2025-06-06 14:22:29 -04:00
committed by GitHub
parent 6605269e5b
commit 345dccbf84
24 changed files with 706 additions and 19 deletions

View File

@@ -6,6 +6,21 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
data: {
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
},
isLoading: false,
}),
};
});
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -20,6 +35,7 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -47,6 +63,10 @@ describe("Settings Billing", () => {
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
],
},
]);

View File

@@ -1,5 +1,60 @@
import axios from "axios";
import axios, { AxiosError, AxiosResponse } from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// Helper function to check if a response contains an email verification error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkForEmailVerificationError = (data: any): boolean => {
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
};
// Set up the global interceptor
openHands.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
// Check if it's a 403 error with the email verification message
if (
error.response?.status === 403 &&
checkForEmailVerificationError(error.response?.data)
) {
if (window.location.pathname !== "/settings/user") {
window.location.reload();
}
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useSettings } from "#/hooks/query/use-settings";
/**
* A component that restricts access to routes based on email verification status.
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
*/
export function EmailVerificationGuard({
children,
}: {
children: React.ReactNode;
}) {
const { data: settings, isLoading } = useSettings();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
// If settings are still loading, don't do anything yet
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
return children;
}

View File

@@ -69,16 +69,21 @@ export function Sidebar() {
<div className="flex items-center justify-center">
<AllHandsLogoButton />
</div>
<NewProjectButton />
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
onClick={() =>
settings?.EMAIL_VERIFIED === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton />
<SettingsButton />
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined

View File

@@ -8,11 +8,13 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -22,10 +24,14 @@ export function ConversationPanelButton({
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<FaListUl
size={22}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
/>
</TooltipButton>
);

View File

@@ -3,15 +3,24 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
export function DocsButton() {
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.SIDEBAR$DOCS)}
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
disabled={disabled}
>
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
</TooltipButton>
);
}

View File

@@ -3,7 +3,11 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
export function NewProjectButton() {
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
@@ -12,6 +16,7 @@ export function NewProjectButton() {
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
>
<PlusIcon width={28} height={28} />
</TooltipButton>

View File

@@ -5,9 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick?: () => void;
disabled?: boolean;
}
export function SettingsButton({ onClick }: SettingsButtonProps) {
export function SettingsButton({
onClick,
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
return (
@@ -17,6 +21,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
</TooltipButton>

View File

@@ -12,6 +12,7 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -23,9 +24,10 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
if (onClick && !disabled) {
onClick();
e.preventDefault();
}
@@ -37,7 +39,12 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn("hover:opacity-80", className)}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
>
{children}
</button>
@@ -45,7 +52,7 @@ export function TooltipButton({
let content;
if (navLinkTo) {
if (navLinkTo && !disabled) {
content = (
<NavLink
to={navLinkTo}
@@ -63,7 +70,24 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (href) {
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
content = (
<a
href={href}
@@ -76,6 +100,19 @@ export function TooltipButton({
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}

View File

@@ -27,7 +27,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};

View File

@@ -556,4 +556,19 @@ export enum I18nKey {
TIPS$PROTIP = "TIPS$PROTIP",
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
}

View File

@@ -8894,5 +8894,245 @@
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
"de": "Feedback senden, bitte warten...",
"uk": "Відправляємо відгук, будь ласка, почекайте..."
},
"SETTINGS$NAV_USER": {
"en": "User",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "Bruker",
"it": "Utente",
"pt": "Usuário",
"es": "Usuario",
"ar": "المستخدم",
"fr": "Utilisateur",
"tr": "Kullanıcı",
"de": "Benutzer",
"uk": "Користувач"
},
"SETTINGS$USER_TITLE": {
"en": "User Information",
"ja": "ユーザー情報",
"zh-CN": "用户信息",
"zh-TW": "用戶信息",
"ko-KR": "사용자 정보",
"no": "Brukerinformasjon",
"it": "Informazioni utente",
"pt": "Informações do usuário",
"es": "Información del usuario",
"ar": "معلومات المستخدم",
"fr": "Informations utilisateur",
"tr": "Kullanıcı Bilgileri",
"de": "Benutzerinformationen",
"uk": "Інформація про користувача"
},
"SETTINGS$USER_EMAIL": {
"en": "Email",
"ja": "メール",
"zh-CN": "邮箱",
"zh-TW": "郵箱",
"ko-KR": "이메일",
"no": "E-post",
"it": "Email",
"pt": "Email",
"es": "Correo electrónico",
"ar": "البريد الإلكتروني",
"fr": "Email",
"tr": "E-posta",
"de": "E-Mail",
"uk": "Електронна пошта"
},
"SETTINGS$USER_EMAIL_LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
},
"SETTINGS$SAVE": {
"en": "Save",
"ja": "保存",
"zh-CN": "保存",
"zh-TW": "儲存",
"ko-KR": "저장",
"no": "Lagre",
"it": "Salva",
"pt": "Salvar",
"es": "Guardar",
"ar": "حفظ",
"fr": "Enregistrer",
"tr": "Kaydet",
"de": "Speichern",
"uk": "Зберегти"
},
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
"en": "Email saved successfully",
"ja": "メールが正常に保存されました",
"zh-CN": "邮箱保存成功",
"zh-TW": "郵箱儲存成功",
"ko-KR": "이메일이 성공적으로 저장되었습니다",
"no": "E-post lagret",
"it": "Email salvata con successo",
"pt": "Email salvo com sucesso",
"es": "Correo electrónico guardado con éxito",
"ar": "تم حفظ البريد الإلكتروني بنجاح",
"fr": "Email enregistré avec succès",
"tr": "E-posta başarıyla kaydedildi",
"de": "E-Mail erfolgreich gespeichert",
"uk": "Електронну пошту успішно збережено"
},
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
"en": "Your email has been verified successfully!",
"ja": "メールアドレスの確認が完了しました!",
"zh-CN": "您的邮箱已成功验证!",
"zh-TW": "您的郵箱已成功驗證!",
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
"no": "E-posten din er bekreftet!",
"it": "La tua email è stata verificata con successo!",
"pt": "Seu email foi verificado com sucesso!",
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
"fr": "Votre email a été vérifié avec succès !",
"tr": "E-postanız başarıyla doğrulandı!",
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
"uk": "Вашу електронну пошту успішно підтверджено!"
},
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
"en": "Failed to save email",
"ja": "メールの保存に失敗しました",
"zh-CN": "保存邮箱失败",
"zh-TW": "儲存郵箱失敗",
"ko-KR": "이메일 저장 실패",
"no": "Kunne ikke lagre e-post",
"it": "Impossibile salvare l'email",
"pt": "Falha ao salvar email",
"es": "Error al guardar el correo electrónico",
"ar": "فشل في حفظ البريد الإلكتروني",
"fr": "Échec de l'enregistrement de l'email",
"tr": "E-posta kaydedilemedi",
"de": "E-Mail konnte nicht gespeichert werden",
"uk": "Не вдалося зберегти електронну пошту"
},
"SETTINGS$SENDING": {
"en": "Sending",
"ja": "送信中",
"zh-CN": "发送中",
"zh-TW": "發送中",
"ko-KR": "전송 중",
"no": "Sender",
"it": "Invio in corso",
"pt": "Enviando",
"es": "Enviando",
"ar": "جاري الإرسال",
"fr": "Envoi en cours",
"tr": "Gönderiliyor",
"de": "Wird gesendet",
"uk": "Надсилання"
},
"SETTINGS$VERIFICATION_EMAIL_SENT": {
"en": "Verification email sent",
"ja": "確認メールを送信しました",
"zh-CN": "验证邮件已发送",
"zh-TW": "驗證郵件已發送",
"ko-KR": "인증 이메일이 전송되었습니다",
"no": "Bekreftelsese-post sendt",
"it": "Email di verifica inviata",
"pt": "Email de verificação enviado",
"es": "Correo de verificación enviado",
"ar": "تم إرسال بريد التحقق",
"fr": "Email de vérification envoyé",
"tr": "Doğrulama e-postası gönderildi",
"de": "Bestätigungs-E-Mail gesendet",
"uk": "Лист підтвердження надіслано"
},
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
"en": "You must verify your email address before using All Hands",
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
"zh-CN": "使用All Hands前您必须验证您的电子邮件地址",
"zh-TW": "使用All Hands前您必須驗證您的電子郵件地址",
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$INVALID_EMAIL_FORMAT": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de e-mail válido",
"es": "Por favor, introduzca una dirección de correo electrónico válida",
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"fr": "Veuillez entrer une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну електронну адресу"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
},
"SETTINGS$RESEND_VERIFICATION": {
"en": "Resend verification",
"ja": "確認メールを再送信",
"zh-CN": "重新发送验证",
"zh-TW": "重新發送驗證",
"ko-KR": "인증 재전송",
"no": "Send bekreftelse på nytt",
"it": "Rinvia verifica",
"pt": "Reenviar verificação",
"es": "Reenviar verificación",
"ar": "إعادة إرسال التحقق",
"fr": "Renvoyer la vérification",
"tr": "Doğrulamayı yeniden gönder",
"de": "Bestätigung erneut senden",
"uk": "Надіслати підтвердження повторно"
},
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
"en": "Failed to resend verification email",
"ja": "確認メールの再送信に失敗しました",
"zh-CN": "重新发送验证邮件失败",
"zh-TW": "重新發送驗證郵件失敗",
"ko-KR": "인증 이메일 재전송 실패",
"no": "Kunne ikke sende bekreftelsese-post på nytt",
"it": "Impossibile rinviare l'email di verifica",
"pt": "Falha ao reenviar email de verificação",
"es": "Error al reenviar el correo de verificación",
"ar": "فشل في إعادة إرسال بريد التحقق",
"fr": "Échec du renvoi de l'email de vérification",
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
}
}

View File

@@ -12,6 +12,7 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("user", "routes/user-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),

View File

@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -204,7 +205,9 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<Outlet />
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
</div>
{renderAuthModal && (

View File

@@ -15,6 +15,7 @@ function SettingsScreen() {
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
@@ -33,10 +34,11 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
navigate("/settings/user");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
<div className="flex items-center gap-3">
<input
type="email"
value={email}
onChange={onEmailChange}
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
{isEmailChanged && !isEmailValid && (
<div
className="text-red-500 text-sm mt-1"
data-testid="email-validation-error"
>
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving || !isEmailValid}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
{emailVerified === false && (
<button
type="button"
onClick={onResendVerification}
disabled={isResendingVerification}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="resend-verification-button"
>
{isResendingVerification
? t("SETTINGS$SENDING")
: t("SETTINGS$RESEND_VERIFICATION")}
</button>
)}
</div>
{children}
</div>
</div>
);
}
function VerificationAlert() {
const { t } = useTranslation();
return (
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
role="alert"
>
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
<p className="text-sm">
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
</p>
</div>
);
}
// These components have been replaced with toast notifications
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
useEffect(() => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
if (settings?.EMAIL_VERIFIED === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
}
return () => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(false);
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
};
const isEmailChanged = email !== originalEmail;
return (
<div data-testid="user-settings-screen" className="flex flex-col h-full">
<div className="p-9 flex flex-col gap-6">
{isLoading ? (
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
) : (
<EmailInputSection
email={email}
onEmailChange={handleEmailChange}
onSaveEmail={handleSaveEmail}
onResendVerification={handleResendVerification}
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;

View File

@@ -19,6 +19,8 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],

View File

@@ -45,6 +45,8 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
export type ApiSettings = {
@@ -68,6 +70,8 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
email_verified?: boolean;
};
export type PostSettings = Settings & {

View File

@@ -15,6 +15,7 @@ from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
@@ -35,10 +36,9 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings_store: SettingsStore = Depends(get_user_settings_store),
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
settings = await settings_store.load()
try:
if not settings:
return JSONResponse(

View File

@@ -25,6 +25,10 @@ class DefaultUserAuth(UserAuth):
"""The default implementation does not support multi tenancy, so user_id is always None"""
return None
async def get_user_email(self) -> str | None:
"""The default implementation does not support multi tenancy, so email is always None"""
return None
async def get_access_token(self) -> SecretStr | None:
"""The default implementation does not support multi tenancy, so access_token is always None"""
return None

View File

@@ -38,6 +38,10 @@ class UserAuth(ABC):
async def get_user_id(self) -> str | None:
"""Get the unique identifier for the current user"""
@abstractmethod
async def get_user_email(self) -> str | None:
"""Get the email for the current user"""
@abstractmethod
async def get_access_token(self) -> SecretStr | None:
"""Get the access token for the current user"""

View File

@@ -40,6 +40,8 @@ class Settings(BaseModel):
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
search_api_key: SecretStr | None = None
email: str | None = None
email_verified: bool | None = None
model_config = {
'validate_assignment': True,

View File

@@ -28,6 +28,9 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')

View File

@@ -27,6 +27,9 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')