Compare commits

...

8 Commits

Author SHA1 Message Date
openhands
489e32c2c0 Fix email update to use /api/settings endpoint 2025-05-31 19:10:03 +00:00
openhands
c189012f0a Fix email update to use query parameter instead of form data 2025-05-31 19:05:02 +00:00
openhands
2407420e17 Make email field editable and add save button in user settings 2025-05-31 18:59:14 +00:00
chuckbutkus
bb0c47c41a Merge branch 'main' into display-email 2025-05-31 01:14:24 -04:00
Chuck Butkus
83e5276de5 Update User Setting tab 2025-05-31 01:13:38 -04:00
openhands
816082a55b Update User tab to display email from settings instead of git user 2025-05-31 04:12:04 +00:00
Chuck Butkus
82d72b145d Add email to Setting class 2025-05-30 23:59:47 -04:00
Chuck Butkus
f8c3470c91 Add get_user_email from UserAuth 2025-05-30 15:43:08 -04:00
13 changed files with 240 additions and 4 deletions

View File

@@ -27,7 +27,7 @@ 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 || "",
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};

View File

@@ -552,4 +552,10 @@ 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_NOT_AVAILABLE = "SETTINGS$USER_EMAIL_NOT_AVAILABLE",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
}

View File

@@ -8830,5 +8830,117 @@
"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_NOT_AVAILABLE": {
"en": "Email not available",
"ja": "メールは利用できません",
"zh-CN": "邮箱不可用",
"zh-TW": "郵箱不可用",
"ko-KR": "이메일을 사용할 수 없습니다",
"no": "E-post ikke tilgjengelig",
"it": "Email non disponibile",
"pt": "Email não disponível",
"es": "Correo electrónico no disponible",
"ar": "البريد الإلكتروني غير متوفر",
"fr": "Email non disponible",
"tr": "E-posta mevcut değil",
"de": "E-Mail nicht verfügbar",
"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$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": "Не вдалося зберегти електронну пошту"
}
}

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

@@ -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,98 @@
import React, { useState, useEffect } 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";
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
}
}, [settings?.EMAIL]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
setSaveSuccess(false);
};
const handleSaveEmail = async () => {
if (email === originalEmail) return;
try {
setIsSaving(true);
// Send email as part of settings update
await openHands.post('/api/settings', { email });
setOriginalEmail(email);
setSaveSuccess(true);
// Invalidate settings query to refresh data
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// Log error but don't show to user
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(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" />
) : (
<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={handleEmailChange}
className="text-base text-primary p-2 bg-base-tertiary rounded border border-tertiary flex-grow"
placeholder={t("SETTINGS$USER_EMAIL_NOT_AVAILABLE")}
data-testid="email-input"
/>
<button
type="button"
onClick={handleSaveEmail}
disabled={!isEmailChanged || isSaving}
className={`px-4 py-2 rounded ${
isEmailChanged && !isSaving
? "bg-primary text-white hover:bg-primary-dark"
: "bg-tertiary text-secondary cursor-not-allowed"
}`}
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
</div>
{saveSuccess && (
<div className="text-sm text-green-500 mt-1">
{t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY")}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
export default UserSettingsScreen;

View File

@@ -45,6 +45,7 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
};
export type ApiSettings = {
@@ -68,6 +69,7 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
};
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

@@ -29,6 +29,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,7 @@ 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
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')