mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-09 00:30:49 +00:00
feat: improve admin settings and user profile
This commit is contained in:
+27
-2
@@ -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<Phase>(
|
||||
stored?.loggedIn ? "idle-app" : "idle-auth",
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showApp && (
|
||||
|
||||
@@ -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<Tab[]>([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 {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -405,7 +405,9 @@ export function UserEditDialog({
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{t(role.roleDisplayName)}
|
||||
{role.roleDisplayName
|
||||
? t(role.roleDisplayName)
|
||||
: role.roleName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.roleName}
|
||||
@@ -453,7 +455,7 @@ export function UserEditDialog({
|
||||
onClick={() => handleAssignRole(role.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t(role.displayName)}
|
||||
{role.displayName ? t(role.displayName) : role.name}
|
||||
</Button>
|
||||
))}
|
||||
{availableRoles.filter(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -318,19 +318,24 @@ export function GeneralSettingsTab({
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordReset}
|
||||
onCheckedChange={handleTogglePasswordReset}
|
||||
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowPasswordReset")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordReset}
|
||||
onCheckedChange={handleTogglePasswordReset}
|
||||
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowPasswordReset")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
{t("admin.allowPasswordResetDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
|
||||
@@ -192,7 +192,9 @@ export function RolesTab(): React.ReactElement {
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono">{role.name}</TableCell>
|
||||
<TableCell>{t(role.displayName)}</TableCell>
|
||||
<TableCell>
|
||||
{role.displayName ? t(role.displayName) : role.name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{role.description || "-"}
|
||||
</TableCell>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -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")}
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
+111
-63
@@ -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<AuthView>("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 (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="flex flex-col gap-4 p-6 border border-border bg-card max-w-sm w-full">
|
||||
<p className="font-bold text-destructive">
|
||||
{t("errors.databaseConnection")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("messages.databaseConnectionFailed")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-sm w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-destructive">
|
||||
{t("errors.databaseConnection")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("messages.databaseConnectionFailed")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</span>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.serverUrl")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-[180px]">
|
||||
{currentServerUrl}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -756,30 +812,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
{/* Right panel — auth form */}
|
||||
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
|
||||
{/* Language selector */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border hover:border-border/80 bg-background hover:bg-muted transition-colors">
|
||||
<Globe className="size-3.5" />
|
||||
<span className="font-mono">{currentLang.label}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
{AVAILABLE_LANGUAGES.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang)}
|
||||
className={`text-xs font-mono ${currentLang.code === lang.code ? "text-accent-brand" : ""}`}
|
||||
>
|
||||
{lang.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col gap-6">
|
||||
{/* TOTP view */}
|
||||
{view === "totp" && (
|
||||
@@ -1183,6 +1215,22 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</span>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user