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", "enterCode": "Enter verification code",
"backupCode": "Or use backup code", "backupCode": "Or use backup code",
"verifyCode": "Verify Code", "verifyCode": "Verify Code",
"redirectingToApp": "Redirecting to app...",
"enableTwoFactor": "Enable Two-Factor Authentication", "enableTwoFactor": "Enable Two-Factor Authentication",
"disableTwoFactor": "Disable Two-Factor Authentication", "disableTwoFactor": "Disable Two-Factor Authentication",
"scanQRCode": "Scan this QR code with your authenticator app", "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 { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
@@ -68,15 +68,18 @@ function AppContent() {
const handleSelectView = () => {}; const handleSelectView = () => {};
const handleAuthSuccess = (authData: { const handleAuthSuccess = useCallback(
isAdmin: boolean; (authData: {
username: string | null; isAdmin: boolean;
userId: string | null; username: string | null;
}) => { userId: string | null;
setIsAuthenticated(true); }) => {
setIsAdmin(authData.isAdmin); setIsAuthenticated(true);
setUsername(authData.username); setIsAdmin(authData.isAdmin);
}; setUsername(authData.username);
},
[],
);
const currentTabData = tabs.find((tab) => tab.id === currentTab); const currentTabData = tabs.find((tab) => tab.id === currentTab);
const showTerminalView = 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 { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
@@ -105,6 +105,33 @@ export function Auth({
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [webviewAuthSuccess, setWebviewAuthSuccess] = 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(() => { useEffect(() => {
setInternalLoggedIn(loggedIn); setInternalLoggedIn(loggedIn);
}, [loggedIn]); }, [loggedIn]);
@@ -688,24 +715,7 @@ export function Auth({
<div className="w-full max-w-4xl h-[90vh]"> <div className="w-full max-w-4xl h-[90vh]">
<ElectronLoginForm <ElectronLoginForm
serverUrl={currentServerUrl} serverUrl={currentServerUrl}
onAuthSuccess={async () => { onAuthSuccess={handleElectronAuthSuccess}
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"));
}
}}
onChangeServer={() => { onChangeServer={() => {
setShowServerConfig(true); setShowServerConfig(true);
}} }}

View File

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

View File

@@ -93,7 +93,7 @@ export function Auth({
const [signupConfirmPassword, setSignupConfirmPassword] = useState(""); const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = 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 [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false); const [firstUser, setFirstUser] = useState(false);
const [firstUserToastShown, setFirstUserToastShown] = useState(false); const [firstUserToastShown, setFirstUserToastShown] = useState(false);
@@ -115,6 +115,7 @@ export function Auth({
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState(""); const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [mobileAuthSuccess, setMobileAuthSuccess] = useState(false);
useEffect(() => { useEffect(() => {
setInternalLoggedIn(loggedIn); setInternalLoggedIn(loggedIn);
@@ -238,7 +239,6 @@ export function Auth({
const [meRes] = await Promise.all([getUserInfo()]); const [meRes] = await Promise.all([getUserInfo()]);
setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
@@ -252,6 +252,12 @@ export function Auth({
postJWTToWebView(); postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setLoading(false);
return;
}
setInternalLoggedIn(true); setInternalLoggedIn(true);
if (tab === "signup") { if (tab === "signup") {
setSignupConfirmPassword(""); setSignupConfirmPassword("");
@@ -271,7 +277,7 @@ export function Auth({
error?.response?.data?.error || error?.response?.data?.error ||
error?.message || error?.message ||
t("errors.unknownError"); t("errors.unknownError");
toast.error(errorMessage); setError(errorMessage);
setInternalLoggedIn(false); setInternalLoggedIn(false);
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
@@ -299,11 +305,11 @@ export function Auth({
message?: string; message?: string;
response?: { data?: { error?: string } }; response?: { data?: { error?: string } };
}; };
toast.error( const errorMessage =
error?.response?.data?.error || error?.response?.data?.error ||
error?.message || error?.message ||
t("errors.failedPasswordReset"), t("errors.failedPasswordReset");
); setError(errorMessage);
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -319,7 +325,9 @@ export function Auth({
toast.success(t("messages.codeVerified")); toast.success(t("messages.codeVerified"));
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } }; 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 { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -358,9 +366,9 @@ export function Auth({
resetPasswordState(); resetPasswordState();
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
toast.error( const errorMessage =
error?.response?.data?.error || t("errors.failedCompleteReset"), error?.response?.data?.error || t("errors.failedCompleteReset");
); setError(errorMessage);
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -403,22 +411,24 @@ export function Auth({
localStorage.setItem("jwt", res.token); localStorage.setItem("jwt", res.token);
} }
setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!res.is_admin); setIsAdmin(!!res.is_admin);
setUsername(res.username || null); setUsername(res.username || null);
setUserId(res.userId || null); setUserId(res.userId || null);
setDbError(null); setDbError(null);
setTimeout(() => { onAuthSuccess({
onAuthSuccess({ isAdmin: !!res.is_admin,
isAdmin: !!res.is_admin, username: res.username || null,
username: res.username || null, userId: res.userId || null,
userId: res.userId || null, });
}); postJWTToWebView();
postJWTToWebView(); if (isReactNativeWebView()) {
}, 100); setMobileAuthSuccess(true);
setTotpLoading(false);
return;
}
setInternalLoggedIn(true); setInternalLoggedIn(true);
setTotpRequired(false); setTotpRequired(false);
@@ -443,7 +453,7 @@ export function Auth({
setTab("login"); setTab("login");
toast.error(t("errors.sessionExpired")); toast.error(t("errors.sessionExpired"));
} else { } else {
toast.error(errorMessage); setError(errorMessage);
} }
} finally { } finally {
setTotpLoading(false); setTotpLoading(false);
@@ -471,7 +481,7 @@ export function Auth({
error?.response?.data?.error || error?.response?.data?.error ||
error?.message || error?.message ||
t("errors.failedOidcLogin"); t("errors.failedOidcLogin");
toast.error(errorMessage); setError(errorMessage);
setOidcLoading(false); setOidcLoading(false);
} }
} }
@@ -482,7 +492,8 @@ export function Auth({
const error = urlParams.get("error"); const error = urlParams.get("error");
if (error) { if (error) {
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
setError(errorMessage);
setOidcLoading(false); setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
return; return;
@@ -494,7 +505,6 @@ export function Auth({
getUserInfo() getUserInfo()
.then((meRes) => { .then((meRes) => {
setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
@@ -508,6 +518,17 @@ export function Auth({
postJWTToWebView(); postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setOidcLoading(false);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
return;
}
setInternalLoggedIn(true); setInternalLoggedIn(true);
window.history.replaceState( window.history.replaceState(
{}, {},
@@ -516,7 +537,7 @@ export function Auth({
); );
}) })
.catch(() => { .catch(() => {
toast.error(t("errors.failedUserInfo")); setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false); setInternalLoggedIn(false);
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
@@ -562,20 +583,53 @@ export function Auth({
style={{ maxHeight: "calc(100vh - 1rem)" }} style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props} {...props}
> >
{isReactNativeWebView() && ( {isReactNativeWebView() && !mobileAuthSuccess && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10"> <Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Smartphone className="h-4 w-4" /> <Smartphone className="h-4 w-4" />
<AlertTitle>{t("auth.mobileApp")}</AlertTitle> <AlertTitle>{t("auth.mobileApp")}</AlertTitle>
<AlertDescription>{t("auth.loggingInToMobileApp")}</AlertDescription> <AlertDescription>{t("auth.loggingInToMobileApp")}</AlertDescription>
</Alert> </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"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription> <AlertDescription>{dbError}</AlertDescription>
</Alert> </Alert>
)} )}
{totpRequired && ( {!mobileAuthSuccess && totpRequired && (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1"> <h2 className="text-xl font-bold mb-1">
@@ -628,7 +682,7 @@ export function Auth({
</div> </div>
)} )}
{internalLoggedIn && !authLoading && ( {!mobileAuthSuccess && internalLoggedIn && !authLoading && (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1"> <h2 className="text-xl font-bold mb-1">
@@ -652,397 +706,410 @@ export function Auth({
</div> </div>
)} )}
{!internalLoggedIn && !authLoading && !totpRequired && ( {!mobileAuthSuccess &&
<> !internalLoggedIn &&
{(() => { !authLoading &&
const hasLogin = passwordLoginAllowed && !firstUser; !totpRequired && (
const hasSignup = <>
(passwordLoginAllowed || firstUser) && registrationAllowed; {(() => {
const hasOIDC = oidcConfigured; const hasLogin = passwordLoginAllowed && !firstUser;
const hasAnyAuth = hasLogin || hasSignup || hasOIDC; const hasSignup =
(passwordLoginAllowed || firstUser) && registrationAllowed;
const hasOIDC = oidcConfigured;
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
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>
);
}
if (!hasAnyAuth) {
return ( return (
<div className="text-center"> <>
<h2 className="text-xl font-bold mb-1"> <div className="flex gap-2 mb-6">
{t("auth.authenticationDisabled")} {passwordLoginAllowed && (
</h2>
<p className="text-muted-foreground">
{t("auth.authenticationDisabledDesc")}
</p>
</div>
);
}
return (
<>
<div className="flex gap-2 mb-6">
{passwordLoginAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
)}
{(passwordLoginAllowed || firstUser) &&
registrationAllowed && (
<button <button
type="button" type="button"
className={cn( className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup" tab === "login"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent", : "bg-muted text-muted-foreground hover:bg-accent",
)} )}
onClick={() => { onClick={() => {
setTab("signup"); setTab("login");
if (tab === "reset") resetPasswordState(); if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields(); if (tab === "signup") clearFormFields();
}} }}
aria-selected={tab === "signup"} aria-selected={tab === "login"}
disabled={loading} disabled={loading || firstUser}
> >
{t("common.register")} {t("common.login")}
</button> </button>
)} )}
{oidcConfigured && ( {(passwordLoginAllowed || firstUser) &&
<button registrationAllowed && (
type="button" <button
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup")
clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
</div>
<Button
type="button" type="button"
className="w-full h-11 mt-2 text-base font-semibold" className={cn(
disabled={oidcLoading} "flex-1 py-2 text-base font-medium rounded-md transition-all",
onClick={handleOIDCLogin} tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading}
> >
{oidcLoading ? Spinner : t("auth.loginWithExternal")} {t("common.register")}
</Button> </button>
</> )}
)} {oidcConfigured && (
{tab === "reset" && ( <button
<> type="button"
{resetStep === "initiate" && ( className={cn(
<> "flex-1 py-2 text-base font-medium rounded-md transition-all",
<Alert variant="destructive" className="mb-4"> tab === "external"
<AlertTitle>{t("common.warning")}</AlertTitle> ? "bg-primary text-primary-foreground shadow"
<AlertDescription> : "bg-muted text-muted-foreground hover:bg-accent",
{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>
</>
)} )}
onClick={() => {
{resetStep === "verify" && ( setTab("external");
<> if (tab === "reset") resetPasswordState();
<div className="text-center text-muted-foreground mb-4"> if (tab === "login" || tab === "signup")
<p> clearFormFields();
{t("auth.enterResetCode")}{" "} }}
<strong>{localUsername}</strong> aria-selected={tab === "external"}
</p> disabled={oidcLoading}
</div> >
<div className="flex flex-col gap-4"> {t("auth.external")}
<div className="flex flex-col gap-2"> </button>
<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-password">
{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> </div>
) : ( <div className="mb-6 text-center">
<form className="flex flex-col gap-5" onSubmit={handleSubmit}> <h2 className="text-xl font-bold mb-1">
<div className="flex flex-col gap-2"> {tab === "login"
<Label htmlFor="username">{t("common.username")}</Label> ? t("auth.loginTitle")
<Input : tab === "signup"
id="username" ? t("auth.registerTitle")
type="text" : tab === "external"
required ? t("auth.loginWithExternal")
className="h-11 text-base" : t("auth.forgotPassword")}
value={localUsername} </h2>
onChange={(e) => setLocalUsername(e.target.value)} </div>
disabled={loading || internalLoggedIn}
/> {tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
</>
)}
{tab === "reset" && (
<>
{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-password">
{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> </div>
<div className="flex flex-col gap-2"> ) : (
<Label htmlFor="password">{t("common.password")}</Label> <form
<PasswordInput className="flex flex-col gap-5"
id="password" onSubmit={handleSubmit}
required >
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password"> <Label htmlFor="username">{t("common.username")}</Label>
{t("common.confirmPassword")} <Input
</Label> id="username"
<PasswordInput type="text"
id="signup-confirm-password"
required required
className="h-11 text-base" className="h-11 text-base"
value={signupConfirmPassword} value={localUsername}
onChange={(e) => onChange={(e) => setLocalUsername(e.target.value)}
setSignupConfirmPassword(e.target.value)
}
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
/> />
</div> </div>
)} <div className="flex flex-col gap-2">
<Button <Label htmlFor="password">{t("common.password")}</Label>
type="submit" <PasswordInput
className="w-full h-11 mt-2 text-base font-semibold" id="password"
disabled={loading || internalLoggedIn} required
> className="h-11 text-base"
{loading value={password}
? Spinner onChange={(e) => setPassword(e.target.value)}
: tab === "login" disabled={loading || internalLoggedIn}
? t("common.login") />
: t("auth.signUp")} </div>
</Button> {tab === "signup" && (
{tab === "login" && ( <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 || internalLoggedIn}
/>
</div>
)}
<Button <Button
type="button" type="submit"
variant="outline" className="w-full h-11 mt-2 text-base font-semibold"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
> >
{t("auth.resetPasswordButton")} {loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button> </Button>
)} {tab === "login" && (
</form> <Button
)} type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border"> <div className="mt-6 pt-4 border-t border-dark-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label className="text-sm text-muted-foreground"> <Label className="text-sm text-muted-foreground">
{t("common.language")} {t("common.language")}
</Label> </Label>
</div>
<LanguageSwitcher />
</div> </div>
<LanguageSwitcher />
</div> </div>
</div>
<div className="mt-4"> <div className="mt-4">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
onClick={() => onClick={() =>
window.open("https://docs.termix.site/install", "_blank") window.open(
} "https://docs.termix.site/install",
> "_blank",
{t("mobile.viewMobileAppDocs")} )
</Button> }
</div> >
</> {t("mobile.viewMobileAppDocs")}
); </Button>
})()} </div>
</> </>
)} );
})()}
</>
)}
</div> </div>
); );
} }