mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +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 { isElectron } from "@/lib/electron";
|
||||||
import { Toaster } from "@/components/sonner";
|
import { Toaster } from "@/components/sonner";
|
||||||
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
||||||
|
import { getUserInfo } from "@/main-axios";
|
||||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||||
import type { FontSizeId } from "@/types/ui-types";
|
import type { FontSizeId } from "@/types/ui-types";
|
||||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
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() {
|
function useWindowWidth() {
|
||||||
const [width, setWidth] = useState(window.innerWidth);
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
@@ -105,7 +111,7 @@ function FullscreenApp() {
|
|||||||
function App() {
|
function App() {
|
||||||
const stored = getStoredAuth();
|
const stored = getStoredAuth();
|
||||||
const [phase, setPhase] = useState<Phase>(
|
const [phase, setPhase] = useState<Phase>(
|
||||||
stored?.loggedIn ? "idle-app" : "idle-auth",
|
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||||
);
|
);
|
||||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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) {
|
function handleLogin(u: string) {
|
||||||
setAuthUsername(u);
|
setAuthUsername(u);
|
||||||
setPhase("fading-in");
|
setPhase("fading-in");
|
||||||
@@ -144,6 +161,14 @@ function App() {
|
|||||||
const appOpacity = phase === "idle-app" ? 1 : 0;
|
const appOpacity = phase === "idle-app" ? 1 : 0;
|
||||||
const authOpacity = phase === "idle-auth" ? 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showApp && (
|
{showApp && (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Separator } from "@/components/separator";
|
import { Separator } from "@/components/separator";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Sheet, SheetContent } from "@/components/sheet";
|
import { Sheet, SheetContent } from "@/components/sheet";
|
||||||
@@ -26,6 +28,7 @@ import type {
|
|||||||
HostFolder,
|
HostFolder,
|
||||||
} from "@/types/ui-types";
|
} from "@/types/ui-types";
|
||||||
import { getSSHHosts } from "@/main-axios";
|
import { getSSHHosts } from "@/main-axios";
|
||||||
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
import type { SSHHostWithStatus } from "@/main-axios";
|
||||||
|
|
||||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||||
@@ -131,6 +134,7 @@ export function AppShell({
|
|||||||
username: string;
|
username: string;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tabs, setTabs] = useState<Tab[]>([DASHBOARD_TAB]);
|
const [tabs, setTabs] = useState<Tab[]>([DASHBOARD_TAB]);
|
||||||
const [activeTabId, setActiveTabId] = useState("dashboard");
|
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
@@ -192,6 +196,42 @@ export function AppShell({
|
|||||||
return () => window.removeEventListener("termix:logout", handle);
|
return () => window.removeEventListener("termix:logout", handle);
|
||||||
}, [onLogout]);
|
}, [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
|
// Load real hosts from API
|
||||||
const loadHosts = useCallback(async () => {
|
const loadHosts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -130,7 +130,14 @@ export function AdminSettings({
|
|||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getAdminOIDCConfig()
|
getAdminOIDCConfig()
|
||||||
.then((res) => {
|
.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) => {
|
.catch((err) => {
|
||||||
if (!err.message?.includes("No server configured")) {
|
if (!err.message?.includes("No server configured")) {
|
||||||
|
|||||||
@@ -405,7 +405,9 @@ export function UserEditDialog({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">
|
||||||
{t(role.roleDisplayName)}
|
{role.roleDisplayName
|
||||||
|
? t(role.roleDisplayName)
|
||||||
|
: role.roleName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{role.roleName}
|
{role.roleName}
|
||||||
@@ -453,7 +455,7 @@ export function UserEditDialog({
|
|||||||
onClick={() => handleAssignRole(role.id)}
|
onClick={() => handleAssignRole(role.id)}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
{t(role.displayName)}
|
{role.displayName ? t(role.displayName) : role.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{availableRoles.filter(
|
{availableRoles.filter(
|
||||||
|
|||||||
@@ -166,13 +166,14 @@ function CreateApiKeyDialog({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
const wasCreated = createdKey !== null;
|
||||||
setCreatedKey(null);
|
setCreatedKey(null);
|
||||||
setName("");
|
setName("");
|
||||||
setSelectedUserId("");
|
setSelectedUserId("");
|
||||||
setExpiresAt("");
|
setExpiresAt("");
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onCreated();
|
if (wasCreated) onCreated();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export function GeneralSettingsTab({
|
|||||||
/>
|
/>
|
||||||
{t("admin.allowPasswordLogin")}
|
{t("admin.allowPasswordLogin")}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allowPasswordReset}
|
checked={allowPasswordReset}
|
||||||
@@ -331,6 +332,10 @@ export function GeneralSettingsTab({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
|
{t("admin.allowPasswordResetDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
<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) => (
|
roles.map((role) => (
|
||||||
<TableRow key={role.id}>
|
<TableRow key={role.id}>
|
||||||
<TableCell className="font-mono">{role.name}</TableCell>
|
<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">
|
<TableCell className="max-w-xs truncate">
|
||||||
{role.description || "-"}
|
{role.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function UserManagementTab({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
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" />
|
<Link2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -151,7 +151,7 @@ export function UserManagementTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
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" />
|
<Unlink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+105
-57
@@ -3,12 +3,6 @@ import { Button } from "@/components/button";
|
|||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { Separator } from "@/components/separator";
|
import { Separator } from "@/components/separator";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -17,11 +11,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Shield,
|
Shield,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Globe,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "@/i18n/i18n";
|
|
||||||
import {
|
import {
|
||||||
loginUser,
|
loginUser,
|
||||||
registerUser,
|
registerUser,
|
||||||
@@ -43,6 +34,45 @@ import {
|
|||||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
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";
|
const STORAGE_KEY = "termix_auth";
|
||||||
|
|
||||||
@@ -91,19 +121,6 @@ const isInElectronWebView = () => {
|
|||||||
return false;
|
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({
|
function PasswordInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -180,10 +197,6 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [view, setView] = useState<AuthView>("login");
|
const [view, setView] = useState<AuthView>("login");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [oidcLoading, setOidcLoading] = 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 [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -206,6 +219,16 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||||
const [resetTempToken, setResetTempToken] = 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 [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
||||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||||
@@ -383,7 +406,9 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchView(v: AuthView) {
|
function switchView(v: AuthView) {
|
||||||
|
const currentUsername = username;
|
||||||
resetAll();
|
resetAll();
|
||||||
|
if (v === "reset") setUsername(currentUsername);
|
||||||
setView(v);
|
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
|
// Electron server config / webview auth success screens
|
||||||
if (isElectron() && !isInElectronWebView()) {
|
if (isElectron() && !isInElectronWebView()) {
|
||||||
if (showServerConfig === null)
|
if (showServerConfig === null)
|
||||||
@@ -696,16 +715,53 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
if (dbConnectionFailed)
|
if (dbConnectionFailed)
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
<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">
|
<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">
|
<p className="font-bold text-destructive">
|
||||||
{t("errors.databaseConnection")}
|
{t("errors.databaseConnection")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("messages.databaseConnectionFailed")}
|
{t("messages.databaseConnectionFailed")}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<Button onClick={() => window.location.reload()}>
|
<Button onClick={() => window.location.reload()}>
|
||||||
{t("common.refresh")}
|
{t("common.refresh")}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -756,30 +812,6 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
|
|
||||||
{/* Right panel — auth form */}
|
{/* Right panel — auth form */}
|
||||||
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
|
<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">
|
<div className="w-full max-w-sm flex flex-col gap-6">
|
||||||
{/* TOTP view */}
|
{/* TOTP view */}
|
||||||
{view === "totp" && (
|
{view === "totp" && (
|
||||||
@@ -1183,6 +1215,22 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -621,6 +621,9 @@
|
|||||||
"allowNewAccountRegistration": "Allow new account registration",
|
"allowNewAccountRegistration": "Allow new account registration",
|
||||||
"allowPasswordLogin": "Allow username/password login",
|
"allowPasswordLogin": "Allow username/password login",
|
||||||
"allowPasswordReset": "Allow password reset via reset code",
|
"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}}",
|
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||||
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
||||||
|
|||||||
Reference in New Issue
Block a user