mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
Merge remote-tracking branch 'origin/dev-2.3.0' into dev-2.3.0
This commit is contained in:
+52
-6
@@ -36,6 +36,10 @@ import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronSe
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
removeSilentSigninFromSearch,
|
||||
shouldTriggerSilentSignin,
|
||||
} from "./silent-signin";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
@@ -234,6 +238,8 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
||||
const [passwordResetAllowed, setPasswordResetAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||
@@ -262,7 +268,8 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
.catch(() => setPasswordResetAllowed(false));
|
||||
getOIDCConfig()
|
||||
.then((res) => setOidcConfigured(!!res))
|
||||
.catch(() => setOidcConfigured(false));
|
||||
.catch(() => setOidcConfigured(false))
|
||||
.finally(() => setOidcConfigLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -643,17 +650,36 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
const handleOIDCLogin = useCallback(async () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
if (isElectron()) {
|
||||
const electronAPI = (window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: (authUrl: string, port: number) => Promise<{ success: boolean; token?: string; error?: string }> } }).electronAPI;
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
authUrl: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.oidcSystemBrowserAuth) {
|
||||
const callbackPort = 17832 + Math.floor(Math.random() * 100);
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe, callbackPort);
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
callbackPort,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(authUrl, callbackPort);
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
localStorage.setItem("jwt_token", result.token);
|
||||
window.location.reload();
|
||||
@@ -679,7 +705,27 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
}, [rememberMe, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
if (oidcConfigured && !isElectron()) {
|
||||
handleOIDCLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
} from "@/main-axios";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
|
||||
import {
|
||||
removeSilentSigninFromSearch,
|
||||
shouldTriggerSilentSignin,
|
||||
} from "./silent-signin";
|
||||
|
||||
function isMissingServerConfigError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
@@ -123,6 +127,8 @@ export function Auth({
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
|
||||
const [resetStep, setResetStep] = useState<
|
||||
"initiate" | "verify" | "newPassword"
|
||||
@@ -248,6 +254,9 @@ export function Auth({
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcConfigLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -625,7 +634,7 @@ export function Auth({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
const handleOIDCLogin = useCallback(async () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||
@@ -648,7 +657,27 @@ export function Auth({
|
||||
toast.error(errorMessage);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
}, [rememberMe, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfigLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
if (oidcConfigured && !isElectron()) {
|
||||
handleOIDCLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function shouldTriggerSilentSignin(search: string) {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, rawValue] of params) {
|
||||
if (key.toLowerCase() !== "silentsignin") continue;
|
||||
const value = rawValue;
|
||||
return value === "" || value === "true" || value === "1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function removeSilentSigninFromSearch(search: string) {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const key of Array.from(params.keys())) {
|
||||
if (key.toLowerCase() === "silentsignin") params.delete(key);
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
return nextSearch ? `?${nextSearch}` : "";
|
||||
}
|
||||
@@ -2551,7 +2551,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
|
||||
<div
|
||||
className="h-full w-full flex flex-col"
|
||||
className="h-full w-full flex flex-col min-h-0"
|
||||
style={{
|
||||
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
||||
}}
|
||||
@@ -2855,7 +2855,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
: "hidden md:flex",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
|
||||
<FileManagerSidebar
|
||||
currentHost={currentHost}
|
||||
currentPath={currentPath}
|
||||
|
||||
@@ -395,7 +395,11 @@ export const GuacamoleDisplay = forwardRef<
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.onfile = (stream: Guacamole.InputStream, mimetype: string, filename: string) => {
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
|
||||
@@ -1981,9 +1981,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
const url = uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
const url =
|
||||
uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
|
||||
@@ -2148,7 +2149,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (
|
||||
!persistenceEnabled &&
|
||||
sessionIdRef.current &&
|
||||
|
||||
+4
-1
@@ -98,7 +98,8 @@ export const FOLDER_COLORS = [
|
||||
export const SPLIT_MODES: { id: SplitMode; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "2-way", label: "2-Way" },
|
||||
{ id: "3-way", label: "3-Way" },
|
||||
{ id: "3-way", label: "3-Way (V)" },
|
||||
{ id: "3-way-horizontal", label: "3-Way (H)" },
|
||||
{ id: "4-way", label: "4-Way" },
|
||||
{ id: "5-way", label: "5-Way" },
|
||||
{ id: "6-way", label: "6-Way" },
|
||||
@@ -108,6 +109,7 @@ export const PANE_COUNTS: Record<SplitMode, number> = {
|
||||
none: 0,
|
||||
"2-way": 2,
|
||||
"3-way": 3,
|
||||
"3-way-horizontal": 3,
|
||||
"4-way": 4,
|
||||
"5-way": 5,
|
||||
"6-way": 6,
|
||||
@@ -117,6 +119,7 @@ export const PANE_LAYOUTS: Record<SplitMode, string> = {
|
||||
none: "",
|
||||
"2-way": "grid-cols-2 grid-rows-1",
|
||||
"3-way": "grid-cols-2 grid-rows-2",
|
||||
"3-way-horizontal": "grid-cols-2 grid-rows-2",
|
||||
"4-way": "grid-cols-2 grid-rows-2",
|
||||
"5-way": "grid-cols-3 grid-rows-2",
|
||||
"6-way": "grid-cols-3 grid-rows-2",
|
||||
|
||||
@@ -1228,6 +1228,7 @@
|
||||
"failedCompleteReset": "Failed to complete password reset",
|
||||
"invalidTotpCode": "Invalid TOTP code",
|
||||
"failedOidcLogin": "Failed to start OIDC login",
|
||||
"silentSigninOidcUnavailable": "Silent sign-in was requested, but OIDC login is not available.",
|
||||
"failedUserInfo": "Failed to get user info after login",
|
||||
"oidcAuthFailed": "OIDC authentication failed",
|
||||
"invalidAuthUrl": "Invalid authorization URL received from backend",
|
||||
|
||||
@@ -2336,6 +2336,7 @@
|
||||
"failedCompleteReset": "密码重置失败",
|
||||
"invalidTotpCode": "无效的 TOTP 代码",
|
||||
"failedOidcLogin": "OIDC 登录启动失败",
|
||||
"silentSigninOidcUnavailable": "已请求静默登录,但当前未启用 OIDC 登录。",
|
||||
"failedUserInfo": "登录后无法获取用户信息",
|
||||
"oidcAuthFailed": "OIDC 身份验证失败",
|
||||
"noTokenReceived": "登录未收到令牌",
|
||||
|
||||
@@ -3326,6 +3326,28 @@ export async function updateRegistrationAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-auto-provision");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "check OIDC auto-provision status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOidcAutoProvision(
|
||||
enabled: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/oidc-auto-provision", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC auto-provision");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordLoginAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<{ allowed: boolean }> {
|
||||
|
||||
@@ -19,6 +19,8 @@ function defaultSizes(mode: SplitMode): {
|
||||
return { rowSizes: [100], rowColSizes: [[50, 50]] };
|
||||
case "3-way":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
|
||||
case "3-way-horizontal":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
|
||||
case "4-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
@@ -444,6 +446,36 @@ export function SplitView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "3-way-horizontal" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<div
|
||||
className="flex min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[0]}%` }}
|
||||
>
|
||||
<div
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${rowColSizes[0][0]}%` }}
|
||||
>
|
||||
{pane(0)}
|
||||
</div>
|
||||
<ColDivider
|
||||
onMouseDown={(e) => onColDivider(e, 0, 0)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{pane(1)}
|
||||
</div>
|
||||
</div>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "4-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row rowIdx={0} paneIndices={[0, 1]} />
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
getAdminOIDCConfig,
|
||||
updateOIDCConfig,
|
||||
disableOIDCConfig,
|
||||
getOidcAutoProvision,
|
||||
updateOidcAutoProvision,
|
||||
isElectron,
|
||||
getUserRoles,
|
||||
assignRoleToUser,
|
||||
@@ -134,6 +136,7 @@ export function AdminSettingsPanel() {
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
|
||||
// OIDC state
|
||||
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
|
||||
const [oidcClientId, setOidcClientId] = useState("");
|
||||
const [oidcClientSecret, setOidcClientSecret] = useState("");
|
||||
const [oidcAuthUrl, setOidcAuthUrl] = useState("");
|
||||
@@ -238,7 +241,7 @@ export function AdminSettingsPanel() {
|
||||
|
||||
async function loadGeneralSettings() {
|
||||
try {
|
||||
const [reg, pwLogin, pwReset, timeout, monitoring, level, guac] =
|
||||
const [reg, pwLogin, pwReset, timeout, monitoring, level, guac, oidcProv] =
|
||||
await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -247,11 +250,14 @@ export function AdminSettingsPanel() {
|
||||
getGlobalMonitoringSettings(),
|
||||
getLogLevel(),
|
||||
getGuacamoleSettings(),
|
||||
getOidcAutoProvision(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
if (pwLogin.status === "fulfilled")
|
||||
setAllowPasswordLogin(pwLogin.value.allowed);
|
||||
if (oidcProv.status === "fulfilled")
|
||||
setOidcAutoProvision(oidcProv.value.enabled);
|
||||
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
|
||||
if (timeout.status === "fulfilled")
|
||||
setSessionTimeout(String(timeout.value.timeoutHours));
|
||||
@@ -321,6 +327,17 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleOidcAutoProvision() {
|
||||
const newVal = !oidcAutoProvision;
|
||||
setOidcAutoProvision(newVal);
|
||||
try {
|
||||
await updateOidcAutoProvision(newVal);
|
||||
} catch {
|
||||
setOidcAutoProvision(!newVal);
|
||||
toast.error("Failed to update OIDC auto-provision setting");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePasswordReset() {
|
||||
const newVal = !allowPasswordReset;
|
||||
setAllowPasswordReset(newVal);
|
||||
@@ -700,6 +717,15 @@ export function AdminSettingsPanel() {
|
||||
onToggle={handleTogglePasswordLogin}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="OIDC Auto-Provision"
|
||||
description="Auto-create accounts for OIDC users even when registration is disabled"
|
||||
>
|
||||
<AdminToggle
|
||||
on={oidcAutoProvision}
|
||||
onToggle={handleToggleOidcAutoProvision}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Allow Password Reset"
|
||||
description="Reset code via Docker logs"
|
||||
|
||||
@@ -24,6 +24,15 @@ const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"3-way-horizontal": (
|
||||
<div className="flex flex-col gap-0.5 size-full">
|
||||
<div className="flex gap-0.5 flex-1">
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-current" />
|
||||
</div>
|
||||
),
|
||||
"4-way": (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
|
||||
<div className="border-2 border-current" />
|
||||
|
||||
Reference in New Issue
Block a user