diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 42f37cd558..c1ebe316a9 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -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("#/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: () =>
, path: "/settings/git", }, + { + Component: () =>
, + path: "/settings/user", + }, ], }, ]); diff --git a/frontend/src/api/open-hands-axios.ts b/frontend/src/api/open-hands-axios.ts index a20003dc66..85ebb764d4 100644 --- a/frontend/src/api/open-hands-axios.ts +++ b/frontend/src/api/open-hands-axios.ts @@ -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); + }, +); diff --git a/frontend/src/components/features/guards/email-verification-guard.tsx b/frontend/src/components/features/guards/email-verification-guard.tsx new file mode 100644 index 0000000000..b212016503 --- /dev/null +++ b/frontend/src/components/features/guards/email-verification-guard.tsx @@ -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; +} diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 84af4f1c57..a78facb3cf 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -69,16 +69,21 @@ export function Sidebar() {
- + setConversationPanelIsOpen((prev) => !prev)} + onClick={() => + settings?.EMAIL_VERIFIED === false + ? null + : setConversationPanelIsOpen((prev) => !prev) + } + disabled={settings?.EMAIL_VERIFIED === false} />
- - + + 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} > ); diff --git a/frontend/src/components/shared/buttons/docs-button.tsx b/frontend/src/components/shared/buttons/docs-button.tsx index 4c2b248e0b..d7ea367ee9 100644 --- a/frontend/src/components/shared/buttons/docs-button.tsx +++ b/frontend/src/components/shared/buttons/docs-button.tsx @@ -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 ( - + ); } diff --git a/frontend/src/components/shared/buttons/new-project-button.tsx b/frontend/src/components/shared/buttons/new-project-button.tsx index 849f37fc29..67cc7aab60 100644 --- a/frontend/src/components/shared/buttons/new-project-button.tsx +++ b/frontend/src/components/shared/buttons/new-project-button.tsx @@ -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} > diff --git a/frontend/src/components/shared/buttons/settings-button.tsx b/frontend/src/components/shared/buttons/settings-button.tsx index 65d3115947..68a8d38fa6 100644 --- a/frontend/src/components/shared/buttons/settings-button.tsx +++ b/frontend/src/components/shared/buttons/settings-button.tsx @@ -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} > diff --git a/frontend/src/components/shared/buttons/tooltip-button.tsx b/frontend/src/components/shared/buttons/tooltip-button.tsx index 324b32f2f3..3c04318e9f 100644 --- a/frontend/src/components/shared/buttons/tooltip-button.tsx +++ b/frontend/src/components/shared/buttons/tooltip-button.tsx @@ -12,6 +12,7 @@ export interface TooltipButtonProps { ariaLabel: string; testId?: string; className?: React.HTMLAttributes["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} @@ -45,7 +52,7 @@ export function TooltipButton({ let content; - if (navLinkTo) { + if (navLinkTo && !disabled) { content = ( ); - } 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 = ( + + ); + } else if (href && !disabled) { content = ( ); + } else if (href && disabled) { + // If disabled and has href, render a button that looks like a link but doesn't navigate + content = ( + + ); } else { content = buttonContent; } diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 3fa916c958..7cff880696 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -27,7 +27,8 @@ const getSettingsQueryFn = async (): Promise => { 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, }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index f051bd0817..f05c306aa0 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index c22d4a3581..799e955d50 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "Не вдалося повторно надіслати лист підтвердження" } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index a1bf89b224..166d62ddf1 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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"), diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 8b29a65e58..92e90f5a4c 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -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" > - + + +
{renderAuthModal && ( diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 3aa4339b01..5a4a9cf043 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -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", diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx new file mode 100644 index 0000000000..2c3a7dadd9 --- /dev/null +++ b/frontend/src/routes/user-settings.tsx @@ -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) => void; + onSaveEmail: () => void; + onResendVerification: () => void; + isSaving: boolean; + isResendingVerification: boolean; + isEmailChanged: boolean; + emailVerified?: boolean; + isEmailValid: boolean; + children: React.ReactNode; +}) { + const { t } = useTranslation(); + return ( +
+
+ +
+ +
+ + {isEmailChanged && !isEmailValid && ( +
+ {t("SETTINGS$INVALID_EMAIL_FORMAT")} +
+ )} + +
+ + + {emailVerified === false && ( + + )} +
+ + {children} +
+
+ ); +} + +function VerificationAlert() { + const { t } = useTranslation(); + return ( +
+

{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}

+

+ {t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")} +

+
+ ); +} + +// 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(null); + const prevVerificationStatusRef = useRef(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) => { + 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 ( +
+
+ {isLoading ? ( +
+ ) : ( + + {settings?.EMAIL_VERIFIED === false && } + + )} +
+
+ ); +} + +export default UserSettingsScreen; diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index bf79e542f9..5890decf26 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -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: [], diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 029bb26d7a..d4d458e399 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -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 & { diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 043765df53..684d043d56 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -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( diff --git a/openhands/server/user_auth/default_user_auth.py b/openhands/server/user_auth/default_user_auth.py index 87a0d5fb11..ad9cb884a0 100644 --- a/openhands/server/user_auth/default_user_auth.py +++ b/openhands/server/user_auth/default_user_auth.py @@ -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 diff --git a/openhands/server/user_auth/user_auth.py b/openhands/server/user_auth/user_auth.py index 2b32297b57..d589480e06 100644 --- a/openhands/server/user_auth/user_auth.py +++ b/openhands/server/user_auth/user_auth.py @@ -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""" diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py index 32c123a3a8..027b7d9047 100644 --- a/openhands/storage/data_models/settings.py +++ b/openhands/storage/data_models/settings.py @@ -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, diff --git a/tests/unit/test_openapi_schema_generation.py b/tests/unit/test_openapi_schema_generation.py index 086172fa12..e2519ad32c 100644 --- a/tests/unit/test_openapi_schema_generation.py +++ b/tests/unit/test_openapi_schema_generation.py @@ -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') diff --git a/tests/unit/test_settings_api.py b/tests/unit/test_settings_api.py index ff4953e47f..4569dab6a2 100644 --- a/tests/unit/test_settings_api.py +++ b/tests/unit/test_settings_api.py @@ -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')