feat: improve admin settings and user profile

This commit is contained in:
LukeGus
2026-05-13 01:28:12 -05:00
parent 1be5d20744
commit ec87f8a4d1
10 changed files with 218 additions and 85 deletions
+27 -2
View File
@@ -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 && (
+40
View File
@@ -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 {
+8 -1
View File
@@ -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")) {
+4 -2
View File
@@ -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(
+2 -1
View File
@@ -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 () => {
+18 -13
View File
@@ -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">
+3 -1
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -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",