mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
d1a8dcf3c6
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1847 lines
66 KiB
TypeScript
1847 lines
66 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
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";
|
|
import { Label } from "@/components/label.tsx";
|
|
import { Checkbox } from "@/components/checkbox.tsx";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/tabs.tsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LanguageSwitcher } from "@/user/LanguageSwitcher.tsx";
|
|
import { toast } from "sonner";
|
|
import { Sun, Moon, Monitor } from "lucide-react";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import {
|
|
registerUser,
|
|
loginUser,
|
|
getUserInfo,
|
|
getRegistrationAllowed,
|
|
getPasswordLoginAllowed,
|
|
getSetupRequired,
|
|
initiatePasswordReset,
|
|
verifyPasswordResetCode,
|
|
completePasswordReset,
|
|
getOIDCAuthorizeUrl,
|
|
verifyTOTPLogin,
|
|
getServerConfig,
|
|
saveServerConfig,
|
|
isElectron,
|
|
getEmbeddedServerStatus,
|
|
getCurrentToken,
|
|
getOidcSilentLoginDefault,
|
|
} from "@/main-axios";
|
|
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
|
import type { SSOProviderPublic } from "@/types/index";
|
|
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
|
|
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
|
|
import {
|
|
removeSilentSigninFromSearch,
|
|
shouldTriggerSilentSignin,
|
|
} from "./silent-signin";
|
|
|
|
interface ExtendedWindow extends Window {
|
|
IS_ELECTRON_WEBVIEW?: boolean;
|
|
ReactNativeWebView?: { postMessage: (msg: string) => void };
|
|
}
|
|
|
|
const isInMobileWebView = () =>
|
|
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
|
|
!!(window as ExtendedWindow).ReactNativeWebView;
|
|
|
|
interface AuthProps extends React.ComponentProps<"div"> {
|
|
setLoggedIn: (loggedIn: boolean) => void;
|
|
setIsAdmin: (isAdmin: boolean) => void;
|
|
setUsername: (username: string | null) => void;
|
|
setUserId: (userId: string | null) => void;
|
|
loggedIn: boolean;
|
|
authLoading: boolean;
|
|
setDbError: (error: string | null) => void;
|
|
onAuthSuccess: (authData: {
|
|
isAdmin: boolean;
|
|
username: string | null;
|
|
userId: string | null;
|
|
}) => void;
|
|
}
|
|
|
|
export function Auth({
|
|
className,
|
|
setLoggedIn,
|
|
setIsAdmin,
|
|
setUsername,
|
|
setUserId,
|
|
loggedIn,
|
|
authLoading,
|
|
setDbError,
|
|
onAuthSuccess,
|
|
...props
|
|
}: AuthProps) {
|
|
const { t } = useTranslation();
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
const isDarkMode =
|
|
theme === "dark" ||
|
|
theme === "dracula" ||
|
|
theme === "gentlemansChoice" ||
|
|
theme === "midnightEspresso" ||
|
|
theme === "catppuccinMocha" ||
|
|
(theme === "system" &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
|
|
|
const isInElectronWebView = useCallback(() => {
|
|
if (isInMobileWebView()) return false;
|
|
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) {
|
|
return true;
|
|
}
|
|
try {
|
|
if (window.self !== window.top) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
return true;
|
|
}
|
|
return false;
|
|
}, []);
|
|
|
|
const [tab, setTab] = useState<"login" | "signup" | "reset">("login");
|
|
const [localUsername, setLocalUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
|
const [rememberMe, setRememberMe] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem("rememberMe");
|
|
return saved === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [oidcLoading, setOidcLoading] = useState(false);
|
|
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
|
const [firstUser, setFirstUser] = useState(false);
|
|
const [firstUserToastShown, setFirstUserToastShown] = useState(false);
|
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
|
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
|
|
const [ssoProviders, setSsoProviders] = useState<SSOProviderPublic[]>([]);
|
|
const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
|
|
const [ldapProviderId, setLdapProviderId] = useState<number | null>(null);
|
|
const [ldapUsername, setLdapUsername] = useState("");
|
|
const [ldapPassword, setLdapPassword] = useState("");
|
|
const [ldapLoading, setLdapLoading] = useState(false);
|
|
const silentSigninHandledRef = useRef(false);
|
|
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
|
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
|
useState(false);
|
|
|
|
const [resetStep, setResetStep] = useState<
|
|
"initiate" | "verify" | "newPassword"
|
|
>("initiate");
|
|
const [resetCode, setResetCode] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [tempToken, setTempToken] = useState("");
|
|
const [resetLoading, setResetLoading] = useState(false);
|
|
const [resetSuccess, setResetSuccess] = useState(false);
|
|
|
|
const [totpRequired, setTotpRequired] = useState(false);
|
|
const [totpCode, setTotpCode] = useState("");
|
|
const [totpTempToken, setTotpTempToken] = useState("");
|
|
const [totpLoading, setTotpLoading] = useState(false);
|
|
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
|
|
const totpInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
// Hand the JWT to the native app embedding this page in a React Native WebView.
|
|
// The mobile onMessage handler only reads { type, token }.
|
|
const postMobileAuthSuccess = useCallback((token: string) => {
|
|
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
|
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
|
);
|
|
setWebviewAuthSuccess(true);
|
|
}, []);
|
|
|
|
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
|
|
null,
|
|
);
|
|
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
|
|
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
|
const [dbHealthChecking, setDbHealthChecking] = useState(false);
|
|
|
|
const handleElectronAuthSuccess = useCallback(async () => {
|
|
try {
|
|
// token was stored in localStorage by ElectronLoginForm before this runs,
|
|
// so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt.
|
|
let retries = 5;
|
|
let meRes = null;
|
|
while (retries-- > 0) {
|
|
try {
|
|
meRes = await getUserInfo();
|
|
break;
|
|
} catch (err: unknown) {
|
|
const isNoServer =
|
|
(err as { code?: string })?.code === "NO_SERVER_CONFIGURED" ||
|
|
(err as Error)?.message?.includes("no-server-configured");
|
|
if (isNoServer && retries > 0) {
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
if (!meRes) throw new Error("Failed to get user info");
|
|
setInternalLoggedIn(true);
|
|
setLoggedIn(true);
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
onAuthSuccess({
|
|
isAdmin: !!meRes.is_admin,
|
|
username: meRes.username || null,
|
|
userId: meRes.userId || null,
|
|
});
|
|
toast.success(t("messages.loginSuccess"));
|
|
} catch {
|
|
toast.error(t("errors.failedUserInfo"));
|
|
}
|
|
}, [
|
|
onAuthSuccess,
|
|
setLoggedIn,
|
|
setIsAdmin,
|
|
setUsername,
|
|
setUserId,
|
|
t,
|
|
setInternalLoggedIn,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setInternalLoggedIn(loggedIn);
|
|
}, [loggedIn]);
|
|
|
|
useEffect(() => {
|
|
if (totpRequired && totpInputRef.current) {
|
|
totpInputRef.current.focus();
|
|
}
|
|
}, [totpRequired]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem("rememberMe", rememberMe.toString());
|
|
} catch {
|
|
// expected - localStorage might not be available
|
|
}
|
|
}, [rememberMe]);
|
|
|
|
useEffect(() => {
|
|
getRegistrationAllowed().then((res) => {
|
|
setRegistrationAllowed(res.allowed);
|
|
});
|
|
}, [isInElectronWebView]);
|
|
|
|
useEffect(() => {
|
|
getPasswordLoginAllowed()
|
|
.then((res) => {
|
|
setPasswordLoginAllowed(res.allowed);
|
|
})
|
|
.catch((err) => {
|
|
if (err.code !== "NO_SERVER_CONFIGURED") {
|
|
console.error("Failed to fetch password login status:", err);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getSSOProviders()
|
|
.then((providers) => {
|
|
setSsoProviders(providers || []);
|
|
})
|
|
.catch(() => {
|
|
setSsoProviders([]);
|
|
})
|
|
.finally(() => {
|
|
setSsoProvidersLoaded(true);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getOidcSilentLoginDefault()
|
|
.then((res) => {
|
|
setOidcSilentLoginDefault(res.enabled);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setOidcSilentLoginDefaultLoaded(true);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (showServerConfig) {
|
|
return;
|
|
}
|
|
|
|
setDbHealthChecking(true);
|
|
getSetupRequired()
|
|
.then((res) => {
|
|
if (res.setup_required) {
|
|
setFirstUser(true);
|
|
setTab("signup");
|
|
if (!firstUserToastShown) {
|
|
toast.info(t("auth.firstUserMessage"));
|
|
setFirstUserToastShown(true);
|
|
}
|
|
} else {
|
|
setFirstUser(false);
|
|
}
|
|
setDbError(null);
|
|
setDbConnectionFailed(false);
|
|
})
|
|
.catch(() => {
|
|
setDbConnectionFailed(true);
|
|
})
|
|
.finally(() => {
|
|
setDbHealthChecking(false);
|
|
});
|
|
}, [setDbError, firstUserToastShown, showServerConfig, t]);
|
|
|
|
// When password login is disabled and SSO is available, stay on login tab
|
|
// (SSO buttons appear below the form regardless of tab)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
|
|
if (!localUsername.trim()) {
|
|
toast.error(t("errors.requiredField"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!passwordLoginAllowed && !firstUser) {
|
|
toast.error(t("errors.passwordLoginDisabled"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let res;
|
|
if (tab === "login") {
|
|
res = await loginUser(localUsername, password, rememberMe);
|
|
} else {
|
|
if (password !== signupConfirmPassword) {
|
|
toast.error(t("errors.passwordMismatch"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (password.length < 6) {
|
|
toast.error(t("errors.minLength", { min: 6 }));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
await registerUser(localUsername, password);
|
|
res = await loginUser(localUsername, password, rememberMe);
|
|
}
|
|
|
|
if (res.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpTempToken(res.temp_token);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!res || !res.success) {
|
|
throw new Error(t("errors.loginFailed"));
|
|
}
|
|
|
|
if (isInMobileWebView()) {
|
|
// Native-app requests get the JWT in the login response body.
|
|
postMobileAuthSuccess(res.token || "");
|
|
return;
|
|
}
|
|
|
|
if (isInElectronWebView()) {
|
|
try {
|
|
window.parent.postMessage(
|
|
{
|
|
type: "AUTH_SUCCESS",
|
|
source: "auth_component",
|
|
platform: "desktop",
|
|
token: res.token || null,
|
|
timestamp: Date.now(),
|
|
},
|
|
"*",
|
|
);
|
|
setWebviewAuthSuccess(true);
|
|
return;
|
|
} catch (e) {
|
|
console.error("Error posting auth success message:", e);
|
|
}
|
|
}
|
|
|
|
const [meRes] = await Promise.all([getUserInfo()]);
|
|
|
|
setInternalLoggedIn(true);
|
|
setLoggedIn(true);
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
setDbError(null);
|
|
onAuthSuccess({
|
|
isAdmin: !!meRes.is_admin,
|
|
username: meRes.username || null,
|
|
userId: meRes.userId || null,
|
|
});
|
|
setInternalLoggedIn(true);
|
|
if (tab === "signup") {
|
|
setSignupConfirmPassword("");
|
|
toast.success(t("messages.registrationSuccess"));
|
|
} else {
|
|
toast.success(t("messages.loginSuccess"));
|
|
}
|
|
setTotpRequired(false);
|
|
setTotpCode("");
|
|
setTotpTempToken("");
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
message?: string;
|
|
response?: { data?: { error?: string } };
|
|
};
|
|
const errorMessage =
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("errors.unknownError");
|
|
toast.error(errorMessage);
|
|
setInternalLoggedIn(false);
|
|
setLoggedIn(false);
|
|
setIsAdmin(false);
|
|
setUsername(null);
|
|
setUserId(null);
|
|
if (error?.response?.data?.error?.includes("Database")) {
|
|
setDbConnectionFailed(true);
|
|
} else {
|
|
setDbError(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleInitiatePasswordReset() {
|
|
setResetLoading(true);
|
|
try {
|
|
await initiatePasswordReset(localUsername);
|
|
setResetStep("verify");
|
|
toast.success(t("messages.resetCodeSent"));
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
message?: string;
|
|
response?: { data?: { error?: string } };
|
|
};
|
|
toast.error(
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("errors.failedPasswordReset"),
|
|
);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleVerifyResetCode() {
|
|
setResetLoading(true);
|
|
try {
|
|
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
|
setTempToken(response.tempToken);
|
|
setResetStep("newPassword");
|
|
toast.success(t("messages.codeVerified"));
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
response?: {
|
|
data?: {
|
|
error?: string;
|
|
code?: string;
|
|
remainingTime?: number;
|
|
remainingAttempts?: number;
|
|
};
|
|
};
|
|
};
|
|
const errorCode = error?.response?.data?.code;
|
|
const remainingTime = error?.response?.data?.remainingTime;
|
|
const remainingAttempts = error?.response?.data?.remainingAttempts;
|
|
|
|
let errorMessage =
|
|
error?.response?.data?.error || t("errors.failedVerifyCode");
|
|
|
|
if (errorCode === "RESET_CODE_RATE_LIMITED") {
|
|
if (remainingTime) {
|
|
errorMessage = t("errors.resetCodeRateLimitedWithTime", {
|
|
time: remainingTime,
|
|
});
|
|
} else {
|
|
errorMessage = t("errors.resetCodeRateLimited");
|
|
}
|
|
} else if (
|
|
remainingAttempts !== undefined &&
|
|
remainingAttempts <= 2 &&
|
|
remainingAttempts > 0
|
|
) {
|
|
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
|
|
}
|
|
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCompletePasswordReset() {
|
|
setResetLoading(true);
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
toast.error(t("errors.passwordMismatch"));
|
|
setResetLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (newPassword.length < 6) {
|
|
toast.error(t("errors.minLength", { min: 6 }));
|
|
setResetLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await completePasswordReset(localUsername, tempToken, newPassword);
|
|
|
|
setResetStep("initiate");
|
|
setResetCode("");
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setTempToken("");
|
|
|
|
setResetSuccess(true);
|
|
toast.success(t("messages.passwordResetSuccess"));
|
|
|
|
setTab("login");
|
|
resetPasswordState();
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { error?: string } } };
|
|
toast.error(
|
|
error?.response?.data?.error || t("errors.failedCompleteReset"),
|
|
);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
function resetPasswordState() {
|
|
setResetStep("initiate");
|
|
setResetCode("");
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setTempToken("");
|
|
setResetSuccess(false);
|
|
setSignupConfirmPassword("");
|
|
}
|
|
|
|
function clearFormFields() {
|
|
setPassword("");
|
|
setSignupConfirmPassword("");
|
|
}
|
|
|
|
async function handleTOTPVerification() {
|
|
if (totpCode.length !== 6) {
|
|
toast.error(t("auth.enterCode"));
|
|
return;
|
|
}
|
|
|
|
setTotpLoading(true);
|
|
|
|
try {
|
|
const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe);
|
|
|
|
if (!res || !res.success) {
|
|
throw new Error(t("errors.loginFailed"));
|
|
}
|
|
|
|
if (isInMobileWebView()) {
|
|
// Native-app requests get the JWT in the verify response body.
|
|
postMobileAuthSuccess(res.token || "");
|
|
setTotpLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (isInElectronWebView()) {
|
|
try {
|
|
window.parent.postMessage(
|
|
{
|
|
type: "AUTH_SUCCESS",
|
|
source: "totp_auth_component",
|
|
platform: "desktop",
|
|
token: res.token || null,
|
|
timestamp: Date.now(),
|
|
},
|
|
"*",
|
|
);
|
|
setWebviewAuthSuccess(true);
|
|
setTotpLoading(false);
|
|
return;
|
|
} catch (e) {
|
|
console.error("Error posting auth success message:", e);
|
|
}
|
|
}
|
|
|
|
setLoggedIn(true);
|
|
setIsAdmin(!!res.is_admin);
|
|
setUsername(res.username || null);
|
|
setUserId(res.userId || null);
|
|
setDbError(null);
|
|
|
|
onAuthSuccess({
|
|
isAdmin: !!res.is_admin,
|
|
username: res.username || null,
|
|
userId: res.userId || null,
|
|
});
|
|
|
|
setInternalLoggedIn(true);
|
|
setTotpRequired(false);
|
|
setTotpCode("");
|
|
setTotpTempToken("");
|
|
toast.success(t("messages.loginSuccess"));
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
message?: string;
|
|
response?: {
|
|
data?: {
|
|
code?: string;
|
|
error?: string;
|
|
remainingTime?: number;
|
|
remainingAttempts?: number;
|
|
};
|
|
};
|
|
};
|
|
const errorCode = error?.response?.data?.code;
|
|
const remainingTime = error?.response?.data?.remainingTime;
|
|
const remainingAttempts = error?.response?.data?.remainingAttempts;
|
|
|
|
let errorMessage =
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("errors.invalidTotpCode");
|
|
|
|
if (errorCode === "SESSION_EXPIRED") {
|
|
setTotpRequired(false);
|
|
setTotpCode("");
|
|
setTotpTempToken("");
|
|
setTab("login");
|
|
toast.error(t("errors.sessionExpired"));
|
|
} else if (errorCode === "TOTP_RATE_LIMITED") {
|
|
if (remainingTime) {
|
|
errorMessage = t("errors.totpRateLimitedWithTime", {
|
|
time: remainingTime,
|
|
});
|
|
} else {
|
|
errorMessage = t("errors.totpRateLimited");
|
|
}
|
|
toast.error(errorMessage);
|
|
} else {
|
|
if (
|
|
remainingAttempts !== undefined &&
|
|
remainingAttempts <= 2 &&
|
|
remainingAttempts > 0
|
|
) {
|
|
errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
|
|
}
|
|
toast.error(errorMessage);
|
|
}
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
const handleOIDCLogin = useCallback(
|
|
async (providerId?: number) => {
|
|
setOidcLoading(true);
|
|
try {
|
|
const authResponse = await getOIDCAuthorizeUrl(
|
|
rememberMe,
|
|
undefined,
|
|
providerId,
|
|
);
|
|
const { auth_url: authUrl } = authResponse;
|
|
|
|
if (!authUrl || authUrl === "undefined") {
|
|
throw new Error(t("errors.invalidAuthUrl"));
|
|
}
|
|
|
|
window.location.replace(authUrl);
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
message?: string;
|
|
response?: { data?: { error?: string } };
|
|
};
|
|
const errorMessage =
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("errors.failedOidcLogin");
|
|
toast.error(errorMessage);
|
|
setOidcLoading(false);
|
|
}
|
|
},
|
|
[rememberMe, t],
|
|
);
|
|
|
|
const handleLDAPLogin = useCallback(
|
|
async (providerId: number) => {
|
|
if (!ldapUsername.trim() || !ldapPassword) {
|
|
toast.error(t("errors.requiredField"));
|
|
return;
|
|
}
|
|
setLdapLoading(true);
|
|
try {
|
|
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
|
|
const meRes = await getUserInfo();
|
|
setInternalLoggedIn(true);
|
|
setLoggedIn(true);
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
setDbError(null);
|
|
onAuthSuccess({
|
|
isAdmin: !!meRes.is_admin,
|
|
username: meRes.username || null,
|
|
userId: meRes.userId || null,
|
|
});
|
|
toast.success(t("messages.loginSuccess"));
|
|
} catch (err: unknown) {
|
|
const error = err as {
|
|
response?: { data?: { error?: string } };
|
|
message?: string;
|
|
};
|
|
toast.error(
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("auth.ldapLoginFailed"),
|
|
);
|
|
} finally {
|
|
setLdapLoading(false);
|
|
}
|
|
},
|
|
[
|
|
ldapUsername,
|
|
ldapPassword,
|
|
rememberMe,
|
|
onAuthSuccess,
|
|
setLoggedIn,
|
|
setIsAdmin,
|
|
setUsername,
|
|
setUserId,
|
|
setDbError,
|
|
t,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
|
if (!oidcSilentLoginDefaultLoaded) return;
|
|
|
|
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
|
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
|
|
|
if (urlTriggered) {
|
|
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
|
window.history.replaceState(
|
|
{},
|
|
document.title,
|
|
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
|
);
|
|
}
|
|
|
|
silentSigninHandledRef.current = true;
|
|
const oidcProvider = ssoProviders.find(
|
|
(p) => p.type === "oidc" || p.type === "github" || p.type === "google",
|
|
);
|
|
if (oidcProvider && !isElectron()) {
|
|
handleOIDCLogin(oidcProvider.id);
|
|
return;
|
|
}
|
|
|
|
if (ssoProviders.length > 0 && !isElectron()) {
|
|
const first = ssoProviders[0];
|
|
if (first.type !== "ldap") handleOIDCLogin(first.id);
|
|
return;
|
|
}
|
|
|
|
if (urlTriggered) {
|
|
toast.info(t("errors.silentSigninOidcUnavailable"));
|
|
}
|
|
}, [
|
|
handleOIDCLogin,
|
|
ssoProvidersLoaded,
|
|
ssoProviders,
|
|
t,
|
|
oidcSilentLoginDefault,
|
|
oidcSilentLoginDefaultLoaded,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const success = urlParams.get("success");
|
|
const error = urlParams.get("error");
|
|
|
|
if (error) {
|
|
if (error === "registration_disabled") {
|
|
toast.error(t("messages.registrationDisabled"));
|
|
} else if (error === "user_not_allowed") {
|
|
toast.error(t("messages.userNotAllowed"));
|
|
} else {
|
|
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
|
}
|
|
setOidcLoading(false);
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
return;
|
|
}
|
|
|
|
if (success) {
|
|
setOidcLoading(true);
|
|
|
|
if (isInMobileWebView()) {
|
|
// The OIDC callback authenticated via an HttpOnly cookie on this origin,
|
|
// so prefer a token in the URL (termix-mobile:-origin callbacks include
|
|
// one), otherwise read it back from the cookie via /users/me/token.
|
|
const finish = (token: string) => {
|
|
postMobileAuthSuccess(token);
|
|
setOidcLoading(false);
|
|
window.history.replaceState(
|
|
{},
|
|
document.title,
|
|
window.location.pathname,
|
|
);
|
|
};
|
|
const urlToken = urlParams.get("token");
|
|
if (urlToken) {
|
|
finish(urlToken);
|
|
} else {
|
|
getCurrentToken()
|
|
.then((token) => finish(token ?? ""))
|
|
.catch(() => finish(""));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isInElectronWebView()) {
|
|
try {
|
|
const urlToken = urlParams.get("token");
|
|
window.parent.postMessage(
|
|
{
|
|
type: "AUTH_SUCCESS",
|
|
source: "oidc_callback",
|
|
platform: "desktop",
|
|
token: urlToken || null,
|
|
timestamp: Date.now(),
|
|
},
|
|
"*",
|
|
);
|
|
setWebviewAuthSuccess(true);
|
|
setOidcLoading(false);
|
|
window.history.replaceState(
|
|
{},
|
|
document.title,
|
|
window.location.pathname,
|
|
);
|
|
return;
|
|
} catch (e) {
|
|
console.error("Error posting auth success message:", e);
|
|
}
|
|
}
|
|
|
|
getUserInfo()
|
|
.then((meRes) => {
|
|
setInternalLoggedIn(true);
|
|
setLoggedIn(true);
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
setDbError(null);
|
|
onAuthSuccess({
|
|
isAdmin: !!meRes.is_admin,
|
|
username: meRes.username || null,
|
|
userId: meRes.userId || null,
|
|
});
|
|
setInternalLoggedIn(true);
|
|
window.history.replaceState(
|
|
{},
|
|
document.title,
|
|
window.location.pathname,
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to get user info after OIDC callback:", err);
|
|
toast.error(t("errors.failedUserInfo"));
|
|
setInternalLoggedIn(false);
|
|
setLoggedIn(false);
|
|
setIsAdmin(false);
|
|
setUsername(null);
|
|
setUserId(null);
|
|
window.history.replaceState(
|
|
{},
|
|
document.title,
|
|
window.location.pathname,
|
|
);
|
|
})
|
|
.finally(() => {
|
|
setOidcLoading(false);
|
|
});
|
|
}
|
|
}, [
|
|
onAuthSuccess,
|
|
setDbError,
|
|
setIsAdmin,
|
|
setLoggedIn,
|
|
setUserId,
|
|
setUsername,
|
|
t,
|
|
isInElectronWebView,
|
|
]);
|
|
|
|
const Spinner = (
|
|
<svg
|
|
className="animate-spin mr-2 h-4 w-4 text-foreground inline-block"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
fill="none"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (dbConnectionFailed) {
|
|
toast.error(t("errors.databaseConnection"));
|
|
}
|
|
}, [dbConnectionFailed, t]);
|
|
|
|
useEffect(() => {
|
|
const checkServerConfig = async () => {
|
|
if (isInElectronWebView()) {
|
|
setShowServerConfig(false);
|
|
return;
|
|
}
|
|
|
|
if (isElectron()) {
|
|
try {
|
|
const [config, status] = await Promise.all([
|
|
getServerConfig(),
|
|
getEmbeddedServerStatus(),
|
|
]);
|
|
|
|
if (
|
|
status?.embedded &&
|
|
status?.running &&
|
|
config &&
|
|
!config.serverUrl
|
|
) {
|
|
setCurrentServerUrl("");
|
|
setShowServerConfig(false);
|
|
return;
|
|
}
|
|
|
|
setCurrentServerUrl(config?.serverUrl || "");
|
|
setShowServerConfig(!config || !config.serverUrl);
|
|
} catch {
|
|
setShowServerConfig(true);
|
|
}
|
|
} else {
|
|
setShowServerConfig(false);
|
|
}
|
|
};
|
|
|
|
checkServerConfig();
|
|
}, []);
|
|
|
|
if (showServerConfig === null && !isInElectronWebView()) {
|
|
return (
|
|
<div
|
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-canvas border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
|
|
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
|
{...props}
|
|
>
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (showServerConfig && !isInElectronWebView()) {
|
|
return (
|
|
<div
|
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-canvas border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
|
|
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
|
{...props}
|
|
>
|
|
<ServerConfigComponent
|
|
onServerConfigured={() => {
|
|
window.location.reload();
|
|
}}
|
|
onUseEmbedded={async () => {
|
|
await saveServerConfig({
|
|
serverUrl: "",
|
|
lastUpdated: new Date().toISOString(),
|
|
});
|
|
setShowServerConfig(false);
|
|
setCurrentServerUrl("");
|
|
}}
|
|
onCancel={() => {
|
|
setShowServerConfig(false);
|
|
}}
|
|
isFirstTime={!currentServerUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
isElectron() &&
|
|
currentServerUrl &&
|
|
authLoading &&
|
|
!isInElectronWebView()
|
|
) {
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
|
style={{
|
|
background: "var(--bg-elevated)",
|
|
backgroundImage: `repeating-linear-gradient(
|
|
45deg,
|
|
transparent,
|
|
transparent 35px,
|
|
${lineColor} 35px,
|
|
${lineColor} 37px
|
|
)`,
|
|
}}
|
|
{...props}
|
|
>
|
|
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="text-center">
|
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
|
<p className="text-muted-foreground">
|
|
{t("common.checkingAuthentication")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isElectron() && currentServerUrl && !loggedIn && !isInElectronWebView()) {
|
|
return (
|
|
<div
|
|
className="w-full h-screen flex items-center justify-center p-4"
|
|
{...props}
|
|
>
|
|
<div className="w-full max-w-4xl h-[90vh]">
|
|
<ElectronLoginForm
|
|
serverUrl={currentServerUrl}
|
|
onAuthSuccess={handleElectronAuthSuccess}
|
|
onChangeServer={() => {
|
|
setShowServerConfig(true);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dbHealthChecking && !dbConnectionFailed) {
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
|
style={{
|
|
background: "var(--bg-elevated)",
|
|
backgroundImage: `repeating-linear-gradient(
|
|
45deg,
|
|
transparent,
|
|
transparent 35px,
|
|
${lineColor} 35px,
|
|
${lineColor} 37px
|
|
)`,
|
|
}}
|
|
{...props}
|
|
>
|
|
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="text-center">
|
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
|
<p className="text-muted-foreground">
|
|
{t("common.checkingDatabase")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dbConnectionFailed) {
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
|
style={{
|
|
background: "var(--bg-elevated)",
|
|
backgroundImage: `repeating-linear-gradient(
|
|
45deg,
|
|
transparent,
|
|
transparent 35px,
|
|
${lineColor} 35px,
|
|
${lineColor} 37px
|
|
)`,
|
|
}}
|
|
{...props}
|
|
>
|
|
<div
|
|
className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300"
|
|
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
|
>
|
|
<div className="mb-6 text-center">
|
|
<h2 className="text-xl font-bold mb-1">
|
|
{t("errors.databaseConnection")}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{t("messages.databaseConnectionFailed")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={dbHealthChecking}
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
{t("common.refresh")}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-4 border-t border-edge space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => {
|
|
const isDark =
|
|
theme === "dark" ||
|
|
(theme === "system" &&
|
|
window.matchMedia("(prefers-color-scheme: dark)")
|
|
.matches);
|
|
setTheme(isDark ? "light" : "dark");
|
|
}}
|
|
>
|
|
{theme === "dark" ||
|
|
(theme === "system" &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches) ? (
|
|
<Moon className="w-4 h-4" />
|
|
) : (
|
|
<Sun className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
<LanguageSwitcher />
|
|
</div>
|
|
{isElectron() && currentServerUrl && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm text-muted-foreground">
|
|
Server
|
|
</Label>
|
|
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
|
{currentServerUrl}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowServerConfig(true)}
|
|
className="h-8 px-3"
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
|
|
{...props}
|
|
>
|
|
<div className="w-full h-full flex flex-col md:flex-row">
|
|
<div
|
|
className="hidden md:flex md:w-2/5 items-center justify-center relative border-r-2 border-edge"
|
|
style={{
|
|
background: "var(--bg-elevated)",
|
|
backgroundImage: `repeating-linear-gradient(
|
|
45deg,
|
|
transparent,
|
|
transparent 35px,
|
|
${lineColor} 35px,
|
|
${lineColor} 37px
|
|
)`,
|
|
}}
|
|
>
|
|
<div className="relative text-center px-8">
|
|
<div
|
|
className="text-7xl font-bold tracking-wider mb-4 text-foreground"
|
|
style={{
|
|
fontFamily:
|
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
}}
|
|
>
|
|
{t("common.appName").toUpperCase()}
|
|
</div>
|
|
<div className="text-lg text-muted-foreground tracking-widest font-light">
|
|
{t("auth.tagline")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex p-6 md:p-12 bg-background overflow-y-auto thin-scrollbar">
|
|
<div className="m-auto w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-edge animate-in fade-in slide-in-from-bottom-4 duration-500 flex flex-col">
|
|
{isInElectronWebView() && !webviewAuthSuccess && (
|
|
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
|
|
<Monitor className="h-4 w-4" />
|
|
<AlertTitle>{t("auth.desktopApp")}</AlertTitle>
|
|
<AlertDescription>
|
|
{t("auth.loggingInToDesktopApp")}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{(isInElectronWebView() || isInMobileWebView()) &&
|
|
webviewAuthSuccess && (
|
|
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
|
<div className="text-center">
|
|
<h2 className="text-xl font-bold mb-2">
|
|
{t("messages.loginSuccess")}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{t("auth.redirectingToApp")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!webviewAuthSuccess && totpRequired && (
|
|
<form
|
|
className="flex flex-col gap-5"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleTOTPVerification();
|
|
}}
|
|
>
|
|
<div className="mb-6 text-center">
|
|
<h2 className="text-xl font-bold mb-1">
|
|
{t("auth.twoFactorAuth")}
|
|
</h2>
|
|
<p className="text-muted-foreground">{t("auth.enterCode")}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="totp-code">{t("auth.verifyCode")}</Label>
|
|
<Input
|
|
ref={totpInputRef}
|
|
id="totp-code"
|
|
name="totp"
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
placeholder="000000"
|
|
maxLength={6}
|
|
value={totpCode}
|
|
onChange={(e) =>
|
|
setTotpCode(e.target.value.replace(/\D/g, ""))
|
|
}
|
|
disabled={totpLoading}
|
|
className="text-center text-2xl tracking-widest font-mono"
|
|
autoComplete="one-time-code"
|
|
/>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{t("auth.backupCode")}
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={totpLoading || totpCode.length < 6}
|
|
>
|
|
{totpLoading ? Spinner : t("auth.verifyCode")}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={totpLoading}
|
|
onClick={() => {
|
|
setTotpRequired(false);
|
|
setTotpCode("");
|
|
setTotpTempToken("");
|
|
}}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
|
|
{!webviewAuthSuccess &&
|
|
!loggedIn &&
|
|
!authLoading &&
|
|
!totpRequired && (
|
|
<>
|
|
{(() => {
|
|
const hasLogin = passwordLoginAllowed && !firstUser;
|
|
const hasSignup =
|
|
(passwordLoginAllowed || firstUser) &&
|
|
registrationAllowed;
|
|
const hasSso = ssoProviders.length > 0;
|
|
const hasAnyAuth = hasLogin || hasSignup || hasSso;
|
|
|
|
if (!hasAnyAuth) {
|
|
return (
|
|
<div className="text-center">
|
|
<h2 className="text-xl font-bold mb-1">
|
|
{t("auth.authenticationDisabled")}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{t("auth.authenticationDisabledDesc")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => {
|
|
const newTab = v as "login" | "signup" | "reset";
|
|
setTab(newTab);
|
|
if (tab === "reset") resetPasswordState();
|
|
if (
|
|
(tab === "login" && newTab === "signup") ||
|
|
(tab === "signup" && newTab === "login")
|
|
) {
|
|
clearFormFields();
|
|
}
|
|
}}
|
|
className="w-full mb-8"
|
|
>
|
|
<TabsList className="w-full">
|
|
{passwordLoginAllowed && (
|
|
<TabsTrigger
|
|
value="login"
|
|
disabled={loading || firstUser}
|
|
className="flex-1"
|
|
>
|
|
{t("common.login")}
|
|
</TabsTrigger>
|
|
)}
|
|
{(passwordLoginAllowed || firstUser) &&
|
|
registrationAllowed && (
|
|
<TabsTrigger
|
|
value="signup"
|
|
disabled={loading}
|
|
className="flex-1"
|
|
>
|
|
{t("common.register")}
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="mb-8 text-center">
|
|
<h2 className="text-2xl font-bold">
|
|
{tab === "login"
|
|
? t("auth.loginTitle")
|
|
: tab === "signup"
|
|
? t("auth.registerTitle")
|
|
: t("auth.forgotPassword")}
|
|
</h2>
|
|
</div>
|
|
|
|
{tab === "reset" ? (
|
|
<div className="flex flex-col gap-5">
|
|
{resetStep === "initiate" && (
|
|
<>
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertTitle>{t("common.warning")}</AlertTitle>
|
|
<AlertDescription>
|
|
{t("auth.dataLossWarning")}
|
|
</AlertDescription>
|
|
</Alert>
|
|
<div className="text-center text-muted-foreground mb-4">
|
|
<p>{t("auth.resetCodeDesc")}</p>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="reset-username">
|
|
{t("common.username")}
|
|
</Label>
|
|
<Input
|
|
id="reset-username"
|
|
type="text"
|
|
required
|
|
className="h-11 text-base"
|
|
value={localUsername}
|
|
onChange={(e) =>
|
|
setLocalUsername(e.target.value)
|
|
}
|
|
disabled={resetLoading}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={
|
|
resetLoading || !localUsername.trim()
|
|
}
|
|
onClick={handleInitiatePasswordReset}
|
|
>
|
|
{resetLoading
|
|
? Spinner
|
|
: t("auth.sendResetCode")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{resetStep === "verify" && (
|
|
<>
|
|
<div className="text-center text-muted-foreground mb-4">
|
|
<p>
|
|
{t("auth.enterResetCode")}{" "}
|
|
<strong>{localUsername}</strong>
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="reset-code">
|
|
{t("auth.resetCode")}
|
|
</Label>
|
|
<Input
|
|
id="reset-code"
|
|
type="text"
|
|
required
|
|
maxLength={6}
|
|
className="h-11 text-base text-center text-lg tracking-widest"
|
|
value={resetCode}
|
|
onChange={(e) =>
|
|
setResetCode(
|
|
e.target.value.replace(/\D/g, ""),
|
|
)
|
|
}
|
|
disabled={resetLoading}
|
|
placeholder="000000"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={
|
|
resetLoading || resetCode.length !== 6
|
|
}
|
|
onClick={handleVerifyResetCode}
|
|
>
|
|
{resetLoading
|
|
? Spinner
|
|
: t("auth.verifyCodeButton")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={resetLoading}
|
|
onClick={() => {
|
|
setResetStep("initiate");
|
|
setResetCode("");
|
|
}}
|
|
>
|
|
{t("common.back")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{resetStep === "newPassword" && !resetSuccess && (
|
|
<>
|
|
<div className="text-center text-muted-foreground mb-4">
|
|
<p>
|
|
{t("auth.enterNewPassword")}{" "}
|
|
<strong>{localUsername}</strong>
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="new-p assword">
|
|
{t("auth.newPassword")}
|
|
</Label>
|
|
<PasswordInput
|
|
id="new-password"
|
|
required
|
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
|
value={newPassword}
|
|
onChange={(e) =>
|
|
setNewPassword(e.target.value)
|
|
}
|
|
disabled={resetLoading}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="confirm-password">
|
|
{t("auth.confirmNewPassword")}
|
|
</Label>
|
|
<PasswordInput
|
|
id="confirm-password"
|
|
required
|
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
|
value={confirmPassword}
|
|
onChange={(e) =>
|
|
setConfirmPassword(e.target.value)
|
|
}
|
|
disabled={resetLoading}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={
|
|
resetLoading ||
|
|
!newPassword ||
|
|
!confirmPassword
|
|
}
|
|
onClick={handleCompletePasswordReset}
|
|
>
|
|
{resetLoading
|
|
? Spinner
|
|
: t("auth.resetPasswordButton")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={resetLoading}
|
|
onClick={() => {
|
|
setResetStep("verify");
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
}}
|
|
>
|
|
{t("common.back")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<form
|
|
className="flex flex-col gap-5"
|
|
onSubmit={handleSubmit}
|
|
>
|
|
{!passwordLoginAllowed &&
|
|
!firstUser &&
|
|
tab === "login" ? (
|
|
<p className="text-center text-muted-foreground text-sm">
|
|
{t("auth.passwordLoginDisabledDesc")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="username">
|
|
{t("common.username")}
|
|
</Label>
|
|
<Input
|
|
id="username"
|
|
type="text"
|
|
required
|
|
className="h-11 text-base"
|
|
value={localUsername}
|
|
onChange={(e) =>
|
|
setLocalUsername(e.target.value)
|
|
}
|
|
disabled={loading || loggedIn}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="password">
|
|
{t("common.password")}
|
|
</Label>
|
|
<PasswordInput
|
|
id="password"
|
|
required
|
|
className="h-11 text-base"
|
|
value={password}
|
|
onChange={(e) =>
|
|
setPassword(e.target.value)
|
|
}
|
|
disabled={loading || loggedIn}
|
|
/>
|
|
</div>
|
|
{tab === "login" && (
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="rememberMe"
|
|
checked={rememberMe}
|
|
onCheckedChange={(checked) =>
|
|
setRememberMe(checked === true)
|
|
}
|
|
disabled={loading || loggedIn}
|
|
/>
|
|
<Label
|
|
htmlFor="rememberMe"
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{t("auth.rememberMe")}
|
|
</Label>
|
|
</div>
|
|
)}
|
|
{tab === "signup" && (
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="signup-confirm-password">
|
|
{t("common.confirmPassword")}
|
|
</Label>
|
|
<PasswordInput
|
|
id="signup-confirm-password"
|
|
required
|
|
className="h-11 text-base"
|
|
value={signupConfirmPassword}
|
|
onChange={(e) =>
|
|
setSignupConfirmPassword(e.target.value)
|
|
}
|
|
disabled={loading || loggedIn}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Button
|
|
type="submit"
|
|
className="w-full h-11 mt-2 text-base font-semibold"
|
|
disabled={loading || internalLoggedIn}
|
|
>
|
|
{loading
|
|
? Spinner
|
|
: tab === "login"
|
|
? t("common.login")
|
|
: t("auth.signUp")}
|
|
</Button>
|
|
{tab === "login" && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={loading || loggedIn}
|
|
onClick={() => {
|
|
setTab("reset");
|
|
resetPasswordState();
|
|
clearFormFields();
|
|
}}
|
|
>
|
|
{t("auth.resetPasswordButton")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{ssoProviders.length > 0 && !isElectron() && (
|
|
<div className="flex flex-col gap-3 pt-2">
|
|
{(passwordLoginAllowed ||
|
|
firstUser ||
|
|
tab === "signup") && (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 border-t border-border" />
|
|
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
|
{t("auth.orContinueWith")}
|
|
</span>
|
|
<div className="flex-1 border-t border-border" />
|
|
</div>
|
|
)}
|
|
{ssoProviders.map((provider) => {
|
|
if (provider.type === "ldap") {
|
|
const isExpanded =
|
|
ldapProviderId === provider.id;
|
|
return (
|
|
<div
|
|
key={provider.id}
|
|
className="flex flex-col gap-2"
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-10 text-sm font-medium"
|
|
onClick={() =>
|
|
setLdapProviderId(
|
|
isExpanded ? null : provider.id,
|
|
)
|
|
}
|
|
>
|
|
{t("auth.loginWithProvider", {
|
|
name: provider.name,
|
|
})}
|
|
</Button>
|
|
{isExpanded && (
|
|
<div className="flex flex-col gap-2 p-3 border border-border rounded-md bg-muted/30">
|
|
<Input
|
|
type="text"
|
|
placeholder={t(
|
|
"auth.ldapUsername",
|
|
)}
|
|
value={ldapUsername}
|
|
onChange={(e) =>
|
|
setLdapUsername(e.target.value)
|
|
}
|
|
className="h-9 text-sm"
|
|
disabled={ldapLoading}
|
|
/>
|
|
<Input
|
|
type="password"
|
|
placeholder={t(
|
|
"auth.ldapPassword",
|
|
)}
|
|
value={ldapPassword}
|
|
onChange={(e) =>
|
|
setLdapPassword(e.target.value)
|
|
}
|
|
className="h-9 text-sm"
|
|
disabled={ldapLoading}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter")
|
|
handleLDAPLogin(provider.id);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
className="w-full h-9 text-sm font-medium"
|
|
disabled={
|
|
ldapLoading ||
|
|
!ldapUsername.trim() ||
|
|
!ldapPassword
|
|
}
|
|
onClick={() =>
|
|
handleLDAPLogin(provider.id)
|
|
}
|
|
>
|
|
{ldapLoading
|
|
? Spinner
|
|
: t("auth.loginWithProvider", {
|
|
name: provider.name,
|
|
})}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Button
|
|
key={provider.id}
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-10 text-sm font-medium"
|
|
disabled={oidcLoading}
|
|
onClick={() =>
|
|
handleOIDCLogin(provider.id)
|
|
}
|
|
>
|
|
{oidcLoading
|
|
? Spinner
|
|
: t("auth.loginWithProvider", {
|
|
name: provider.name,
|
|
})}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
<div className="mt-6 pt-4 border-t border-edge space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => {
|
|
const isDark =
|
|
theme === "dark" ||
|
|
(theme === "system" &&
|
|
window.matchMedia(
|
|
"(prefers-color-scheme: dark)",
|
|
).matches);
|
|
setTheme(isDark ? "light" : "dark");
|
|
}}
|
|
>
|
|
{theme === "dark" ||
|
|
(theme === "system" &&
|
|
window.matchMedia(
|
|
"(prefers-color-scheme: dark)",
|
|
).matches) ? (
|
|
<Moon className="w-4 h-4" />
|
|
) : (
|
|
<Sun className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
<LanguageSwitcher />
|
|
</div>
|
|
{isElectron() && currentServerUrl && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm text-muted-foreground">
|
|
{t("serverConfig.serverUrl")}
|
|
</Label>
|
|
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
|
{currentServerUrl}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowServerConfig(true)}
|
|
className="h-8 px-3"
|
|
>
|
|
{t("common.edit")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|