diff --git a/src/main.tsx b/src/main.tsx index ff34a7ee..c9cb6666 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; import { Toaster } from "@/components/sonner"; import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; +import { getUserInfo } from "@/main-axios"; import { applyAccentColor, applyFontSize } from "@/lib/theme"; import type { FontSizeId } from "@/types/ui-types"; import { useServiceWorker } from "@/hooks/use-service-worker"; @@ -34,7 +35,12 @@ const ElectronVersionCheck = lazy(() => })), ); -type Phase = "idle-auth" | "fading-in" | "idle-app" | "fading-out"; +type Phase = + | "verifying" + | "idle-auth" + | "fading-in" + | "idle-app" + | "fading-out"; function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); @@ -105,7 +111,7 @@ function FullscreenApp() { function App() { const stored = getStoredAuth(); const [phase, setPhase] = useState( - stored?.loggedIn ? "idle-app" : "idle-auth", + stored?.loggedIn ? "verifying" : "idle-auth", ); const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const timerRef = useRef | null>(null); @@ -122,6 +128,17 @@ function App() { }; }, []); + // Verify stored session against the server before rendering AppShell + useEffect(() => { + if (phase !== "verifying") return; + getUserInfo() + .then(() => setPhase("idle-app")) + .catch(() => { + clearStoredAuth(); + setPhase("idle-auth"); + }); + }, [phase]); + function handleLogin(u: string) { setAuthUsername(u); setPhase("fading-in"); @@ -144,6 +161,14 @@ function App() { const appOpacity = phase === "idle-app" ? 1 : 0; const authOpacity = phase === "idle-auth" ? 1 : 0; + if (phase === "verifying") { + return ( +
+
+
+ ); + } + return ( <> {showApp && ( diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 6dbda7f5..8c789c43 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -1,3 +1,5 @@ +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; @@ -26,6 +28,7 @@ import type { HostFolder, } from "@/types/ui-types"; import { getSSHHosts } from "@/main-axios"; +import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; function sshHostToHost(h: SSHHostWithStatus): Host { @@ -131,6 +134,7 @@ export function AppShell({ username: string; onLogout: () => void; }) { + const { t } = useTranslation(); const [tabs, setTabs] = useState([DASHBOARD_TAB]); const [activeTabId, setActiveTabId] = useState("dashboard"); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); @@ -192,6 +196,42 @@ export function AppShell({ return () => window.removeEventListener("termix:logout", handle); }, [onLogout]); + useEffect(() => { + const handleSessionExpired = () => onLogout(); + dbHealthMonitor.on("session-expired", handleSessionExpired); + return () => dbHealthMonitor.off("session-expired", handleSessionExpired); + }, [onLogout]); + + useEffect(() => { + const handleDegraded = () => { + toast.loading(t("common.connectionDegraded"), { + id: "db-connection-degraded", + duration: Infinity, + dismissible: false, + action: { + label: t("common.reload"), + onClick: () => window.location.reload(), + }, + }); + }; + + const handleRestored = () => { + toast.dismiss("db-connection-degraded"); + toast.success(t("common.backendReconnected"), { duration: 3000 }); + }; + + dbHealthMonitor.on("database-connection-degraded", handleDegraded); + dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); + + return () => { + dbHealthMonitor.off("database-connection-degraded", handleDegraded); + dbHealthMonitor.off( + "database-connection-degraded-cleared", + handleRestored, + ); + }; + }, [t]); + // Load real hosts from API const loadHosts = useCallback(async () => { try { diff --git a/src/ui/admin/AdminSettings.tsx b/src/ui/admin/AdminSettings.tsx index d1b8a511..b939245c 100644 --- a/src/ui/admin/AdminSettings.tsx +++ b/src/ui/admin/AdminSettings.tsx @@ -130,7 +130,14 @@ export function AdminSettings({ Promise.allSettled([ getAdminOIDCConfig() .then((res) => { - if (res) setOidcConfig(res); + if (res) + setOidcConfig((prev) => ({ + ...prev, + ...res, + identifier_path: (res.identifier_path as string) || "sub", + name_path: (res.name_path as string) || "name", + scopes: (res.scopes as string) || "openid email profile", + })); }) .catch((err) => { if (!err.message?.includes("No server configured")) { diff --git a/src/ui/admin/dialogs/UserEditDialog.tsx b/src/ui/admin/dialogs/UserEditDialog.tsx index 0f81cc9d..51b79382 100644 --- a/src/ui/admin/dialogs/UserEditDialog.tsx +++ b/src/ui/admin/dialogs/UserEditDialog.tsx @@ -405,7 +405,9 @@ export function UserEditDialog({ >

- {t(role.roleDisplayName)} + {role.roleDisplayName + ? t(role.roleDisplayName) + : role.roleName}

{role.roleName} @@ -453,7 +455,7 @@ export function UserEditDialog({ onClick={() => handleAssignRole(role.id)} > - {t(role.displayName)} + {role.displayName ? t(role.displayName) : role.name} ))} {availableRoles.filter( diff --git a/src/ui/admin/tabs/ApiKeysTab.tsx b/src/ui/admin/tabs/ApiKeysTab.tsx index 9528bb61..18e95ea8 100644 --- a/src/ui/admin/tabs/ApiKeysTab.tsx +++ b/src/ui/admin/tabs/ApiKeysTab.tsx @@ -166,13 +166,14 @@ function CreateApiKeyDialog({ }, [open]); const handleClose = () => { + const wasCreated = createdKey !== null; setCreatedKey(null); setName(""); setSelectedUserId(""); setExpiresAt(""); setCopied(false); onOpenChange(false); - onCreated(); + if (wasCreated) onCreated(); }; const handleCreate = async () => { diff --git a/src/ui/admin/tabs/GeneralSettingsTab.tsx b/src/ui/admin/tabs/GeneralSettingsTab.tsx index a1c9ddb0..84fe24eb 100644 --- a/src/ui/admin/tabs/GeneralSettingsTab.tsx +++ b/src/ui/admin/tabs/GeneralSettingsTab.tsx @@ -318,19 +318,24 @@ export function GeneralSettingsTab({ /> {t("admin.allowPasswordLogin")} - +

+ +

+ {t("admin.allowPasswordResetDesc")} +

+
diff --git a/src/ui/admin/tabs/RolesTab.tsx b/src/ui/admin/tabs/RolesTab.tsx index 520153f5..b255897d 100644 --- a/src/ui/admin/tabs/RolesTab.tsx +++ b/src/ui/admin/tabs/RolesTab.tsx @@ -192,7 +192,9 @@ export function RolesTab(): React.ReactElement { roles.map((role) => ( {role.name} - {t(role.displayName)} + + {role.displayName ? t(role.displayName) : role.name} + {role.description || "-"} diff --git a/src/ui/admin/tabs/UserManagementTab.tsx b/src/ui/admin/tabs/UserManagementTab.tsx index a1a82616..b134d08f 100644 --- a/src/ui/admin/tabs/UserManagementTab.tsx +++ b/src/ui/admin/tabs/UserManagementTab.tsx @@ -140,7 +140,7 @@ export function UserManagementTab({ }) } className="text-purple-600 hover:text-purple-700 hover:bg-purple-50" - title="Link to password account" + title={t("admin.linkOidcTitle")} > @@ -151,7 +151,7 @@ export function UserManagementTab({ size="sm" onClick={() => onUnlinkOIDC(user.id, user.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50" - title="Unlink OIDC (keep password only)" + title={t("admin.unlinkOidcTitle")} > diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 9e5a717e..142840d2 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -3,12 +3,6 @@ import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Separator } from "@/components/separator"; import { toast } from "sonner"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; import { Eye, EyeOff, @@ -17,11 +11,8 @@ import { ArrowLeft, Shield, CheckCircle2, - Globe, - ChevronDown, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import i18n from "@/i18n/i18n"; import { loginUser, registerUser, @@ -43,6 +34,45 @@ import { import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; +import i18n from "@/i18n/i18n"; + +const LANGUAGES = [ + { code: "en", label: "English" }, + { code: "af", label: "Afrikaans" }, + { code: "ar", label: "العربية" }, + { code: "bn", label: "বাংলা" }, + { code: "bg", label: "Български" }, + { code: "ca", label: "Català" }, + { code: "zh-CN", label: "中文 (简体)" }, + { code: "zh-TW", label: "中文 (繁體)" }, + { code: "cs", label: "Čeština" }, + { code: "da", label: "Dansk" }, + { code: "nl", label: "Nederlands" }, + { code: "fi", label: "Suomi" }, + { code: "fr", label: "Français" }, + { code: "de", label: "Deutsch" }, + { code: "el", label: "Ελληνικά" }, + { code: "he", label: "עברית" }, + { code: "hi", label: "हिन्दी" }, + { code: "hu", label: "Magyar" }, + { code: "id", label: "Indonesia" }, + { code: "it", label: "Italiano" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "no", label: "Norsk" }, + { code: "pl", label: "Polski" }, + { code: "pt-PT", label: "Português (PT)" }, + { code: "pt-BR", label: "Português (BR)" }, + { code: "ro", label: "Română" }, + { code: "ru", label: "Русский" }, + { code: "sr", label: "Српски" }, + { code: "es-ES", label: "Español" }, + { code: "sv-SE", label: "Svenska" }, + { code: "th", label: "ไทย" }, + { code: "tr", label: "Türkçe" }, + { code: "uk", label: "Українська" }, + { code: "vi", label: "Tiếng Việt" }, +]; const STORAGE_KEY = "termix_auth"; @@ -91,19 +121,6 @@ const isInElectronWebView = () => { return false; }; -const AVAILABLE_LANGUAGES = [ - { code: "en", label: "English" }, - { code: "es", label: "Español" }, - { code: "fr", label: "Français" }, - { code: "de", label: "Deutsch" }, - { code: "zh", label: "中文" }, - { code: "ja", label: "日本語" }, - { code: "pt", label: "Português" }, - { code: "ru", label: "Русский" }, - { code: "ar", label: "العربية" }, - { code: "ko", label: "한국어" }, -]; - function PasswordInput({ value, onChange, @@ -180,10 +197,6 @@ export function Auth({ onLogin }: AuthProps) { const [view, setView] = useState("login"); const [loading, setLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); - const [currentLang, setCurrentLang] = useState( - AVAILABLE_LANGUAGES.find((l) => l.code === i18n.language) ?? - AVAILABLE_LANGUAGES[0], - ); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -206,6 +219,16 @@ export function Auth({ onLogin }: AuthProps) { const [confirmNewPassword, setConfirmNewPassword] = useState(""); const [resetTempToken, setResetTempToken] = useState(""); + const [language, setLanguage] = useState( + () => localStorage.getItem("i18nextLng") ?? "en", + ); + + function handleLanguageChange(code: string) { + setLanguage(code); + localStorage.setItem("i18nextLng", code); + i18n.changeLanguage(code); + } + const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); @@ -383,7 +406,9 @@ export function Auth({ onLogin }: AuthProps) { } function switchView(v: AuthView) { + const currentUsername = username; resetAll(); + if (v === "reset") setUsername(currentUsername); setView(v); } @@ -635,12 +660,6 @@ export function Auth({ onLogin }: AuthProps) { } } - function changeLanguage(lang: (typeof AVAILABLE_LANGUAGES)[0]) { - setCurrentLang(lang); - i18n.changeLanguage(lang.code); - localStorage.setItem("termix-language", lang.code); - } - // Electron server config / webview auth success screens if (isElectron() && !isInElectronWebView()) { if (showServerConfig === null) @@ -696,16 +715,53 @@ export function Auth({ onLogin }: AuthProps) { if (dbConnectionFailed) return (
-
-

- {t("errors.databaseConnection")} -

-

- {t("messages.databaseConnectionFailed")} -

+
+
+

+ {t("errors.databaseConnection")} +

+

+ {t("messages.databaseConnectionFailed")} +

+
+
+ + {t("common.language")} + + +
+ {isElectron() && currentServerUrl && ( +
+
+ + {t("serverConfig.serverUrl")} + + + {currentServerUrl} + +
+ +
+ )}
); @@ -756,30 +812,6 @@ export function Auth({ onLogin }: AuthProps) { {/* Right panel — auth form */}
- {/* Language selector */} -
- - - - - - {AVAILABLE_LANGUAGES.map((lang) => ( - changeLanguage(lang)} - className={`text-xs font-mono ${currentLang.code === lang.code ? "text-accent-brand" : ""}`} - > - {lang.label} - - ))} - - -
-
{/* TOTP view */} {view === "totp" && ( @@ -1183,6 +1215,22 @@ export function Auth({ onLogin }: AuthProps) { ) : null}

+
+ + {t("common.language")} + + +
)}
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 723bb0fd..2e1d0395 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -621,6 +621,9 @@ "allowNewAccountRegistration": "Allow new account registration", "allowPasswordLogin": "Allow username/password login", "allowPasswordReset": "Allow password reset via reset code", + "allowPasswordResetDesc": "Reset codes are delivered via Docker container logs, not email.", + "linkOidcTitle": "Link to password account", + "unlinkOidcTitle": "Unlink OIDC (keep password only)", "missingRequiredFields": "Missing required fields: {{fields}}", "oidcConfigurationUpdated": "OIDC configuration updated successfully!", "failedToFetchOidcConfig": "Failed to fetch OIDC configuration",