feat: Added mobile and electron UI redirecting system

This commit is contained in:
LukeGus
2025-11-03 20:17:56 -06:00
parent fe36631ff3
commit 1e08a179bd
5 changed files with 497 additions and 417 deletions

View File

@@ -1252,6 +1252,7 @@
"enterCode": "Enter verification code",
"backupCode": "Or use backup code",
"verifyCode": "Verify Code",
"redirectingToApp": "Redirecting to app...",
"enableTwoFactor": "Enable Two-Factor Authentication",
"disableTwoFactor": "Disable Two-Factor Authentication",
"scanQRCode": "Scan this QR code with your authenticator app",

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
@@ -68,7 +68,8 @@ function AppContent() {
const handleSelectView = () => {};
const handleAuthSuccess = (authData: {
const handleAuthSuccess = useCallback(
(authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
@@ -76,7 +77,9 @@ function AppContent() {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
};
},
[],
);
const currentTabData = tabs.find((tab) => tab.id === currentTab);
const showTerminalView =

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
@@ -105,6 +105,33 @@ export function Auth({
const [totpLoading, setTotpLoading] = useState(false);
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
const handleElectronAuthSuccess = useCallback(async () => {
try {
const meRes = await getUserInfo();
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 (err) {
toast.error(t("errors.failedUserInfo"));
}
}, [
onAuthSuccess,
setLoggedIn,
setIsAdmin,
setUsername,
setUserId,
t,
setInternalLoggedIn,
]);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
@@ -688,24 +715,7 @@ export function Auth({
<div className="w-full max-w-4xl h-[90vh]">
<ElectronLoginForm
serverUrl={currentServerUrl}
onAuthSuccess={async () => {
try {
const meRes = await getUserInfo();
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 (err) {
toast.error(t("errors.failedUserInfo"));
}
}}
onAuthSuccess={handleElectronAuthSuccess}
onChangeServer={() => {
setShowServerConfig(true);
}}

View File

@@ -57,8 +57,7 @@ export function ElectronLoginForm({
if (
data.type === "AUTH_SUCCESS" &&
data.token &&
!hasAuthenticatedRef.current &&
!isAuthenticating
!hasAuthenticatedRef.current
) {
hasAuthenticatedRef.current = true;
setIsAuthenticating(true);
@@ -110,7 +109,7 @@ export function ElectronLoginForm({
return () => {
window.removeEventListener("message", handleMessage);
};
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
}, [serverUrl, onAuthSuccess, t]);
useEffect(() => {
const checkWebviewUrl = () => {

View File

@@ -93,7 +93,7 @@ export function Auth({
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [firstUserToastShown, setFirstUserToastShown] = useState(false);
@@ -115,6 +115,7 @@ export function Auth({
const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
const [mobileAuthSuccess, setMobileAuthSuccess] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
@@ -238,7 +239,6 @@ export function Auth({
const [meRes] = await Promise.all([getUserInfo()]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
@@ -252,6 +252,12 @@ export function Auth({
postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setLoading(false);
return;
}
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
@@ -271,7 +277,7 @@ export function Auth({
error?.response?.data?.error ||
error?.message ||
t("errors.unknownError");
toast.error(errorMessage);
setError(errorMessage);
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -299,11 +305,11 @@ export function Auth({
message?: string;
response?: { data?: { error?: string } };
};
toast.error(
const errorMessage =
error?.response?.data?.error ||
error?.message ||
t("errors.failedPasswordReset"),
);
t("errors.failedPasswordReset");
setError(errorMessage);
} finally {
setResetLoading(false);
}
@@ -319,7 +325,9 @@ export function Auth({
toast.success(t("messages.codeVerified"));
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
toast.error(error?.response?.data?.error || t("errors.failedVerifyCode"));
const errorMessage =
error?.response?.data?.error || t("errors.failedVerifyCode");
setError(errorMessage);
} finally {
setResetLoading(false);
}
@@ -358,9 +366,9 @@ export function Auth({
resetPasswordState();
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
toast.error(
error?.response?.data?.error || t("errors.failedCompleteReset"),
);
const errorMessage =
error?.response?.data?.error || t("errors.failedCompleteReset");
setError(errorMessage);
} finally {
setResetLoading(false);
}
@@ -403,22 +411,24 @@ export function Auth({
localStorage.setItem("jwt", res.token);
}
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!res.is_admin);
setUsername(res.username || null);
setUserId(res.userId || null);
setDbError(null);
setTimeout(() => {
onAuthSuccess({
isAdmin: !!res.is_admin,
username: res.username || null,
userId: res.userId || null,
});
postJWTToWebView();
}, 100);
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setTotpLoading(false);
return;
}
setInternalLoggedIn(true);
setTotpRequired(false);
@@ -443,7 +453,7 @@ export function Auth({
setTab("login");
toast.error(t("errors.sessionExpired"));
} else {
toast.error(errorMessage);
setError(errorMessage);
}
} finally {
setTotpLoading(false);
@@ -471,7 +481,7 @@ export function Auth({
error?.response?.data?.error ||
error?.message ||
t("errors.failedOidcLogin");
toast.error(errorMessage);
setError(errorMessage);
setOidcLoading(false);
}
}
@@ -482,7 +492,8 @@ export function Auth({
const error = urlParams.get("error");
if (error) {
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
setError(errorMessage);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
@@ -494,7 +505,6 @@ export function Auth({
getUserInfo()
.then((meRes) => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
@@ -508,6 +518,17 @@ export function Auth({
postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setOidcLoading(false);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
return;
}
setInternalLoggedIn(true);
window.history.replaceState(
{},
@@ -516,7 +537,7 @@ export function Auth({
);
})
.catch(() => {
toast.error(t("errors.failedUserInfo"));
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -562,20 +583,53 @@ export function Auth({
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
{isReactNativeWebView() && (
{isReactNativeWebView() && !mobileAuthSuccess && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Smartphone className="h-4 w-4" />
<AlertTitle>{t("auth.mobileApp")}</AlertTitle>
<AlertDescription>{t("auth.loggingInToMobileApp")}</AlertDescription>
</Alert>
)}
{dbError && (
{isReactNativeWebView() && mobileAuthSuccess && (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center">
<svg
className="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<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>
)}
{!mobileAuthSuccess && error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t("common.error", "Error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!mobileAuthSuccess && dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{totpRequired && (
{!mobileAuthSuccess && totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
@@ -628,7 +682,7 @@ export function Auth({
</div>
)}
{internalLoggedIn && !authLoading && (
{!mobileAuthSuccess && internalLoggedIn && !authLoading && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
@@ -652,7 +706,10 @@ export function Auth({
</div>
)}
{!internalLoggedIn && !authLoading && !totpRequired && (
{!mobileAuthSuccess &&
!internalLoggedIn &&
!authLoading &&
!totpRequired && (
<>
{(() => {
const hasLogin = passwordLoginAllowed && !firstUser;
@@ -765,7 +822,9 @@ export function Auth({
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
</>
)}
@@ -802,7 +861,9 @@ export function Auth({
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
disabled={
resetLoading || !localUsername.trim()
}
onClick={handleInitiatePasswordReset}
>
{resetLoading
@@ -945,7 +1006,10 @@ export function Auth({
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<form
className="flex flex-col gap-5"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
@@ -1032,7 +1096,10 @@ export function Auth({
variant="outline"
className="w-full h-11 text-base font-semibold"
onClick={() =>
window.open("https://docs.termix.site/install", "_blank")
window.open(
"https://docs.termix.site/install",
"_blank",
)
}
>
{t("mobile.viewMobileAppDocs")}