* fix: Remove empty catch blocks and add error logging
* refactor: Modularize server stats widget collectors
* feat: Add i18n support for terminal customization and login stats
- Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages
- Add SSH login statistics translations
- Update HostManagerEditor to use i18n for all terminal customization UI elements
- Update LoginStatsWidget to use i18n for all UI text
- Add missing logger imports in backend files for improved debugging
* feat: Add keyboard shortcut enhancements with Kbd component
- Add shadcn kbd component for displaying keyboard shortcuts
- Enhance file manager context menu to display shortcuts with Kbd component
- Add 5 new keyboard shortcuts to file manager:
- Ctrl+D: Download selected files
- Ctrl+N: Create new file
- Ctrl+Shift+N: Create new folder
- Ctrl+U: Upload files
- Enter: Open/run selected file
- Add keyboard shortcut hints to command palette footer
- Create helper function to parse and render keyboard shortcuts
* feat: Add i18n support for command palette
- Add commandPalette translation section with 22 keys to all 4 languages
- Update CommandPalette component to use i18n for all UI text
- Translate search placeholder, group headings, menu items, and shortcut hints
- Support multilingual command palette interface
* feat: Add smooth transitions and animations to UI
- Add fade-in/fade-out transition to command palette (200ms)
- Add scale animation to command palette on open/close
- Add smooth popup animation to context menu (150ms)
- Add visual feedback for file selection with ring effect
- Add hover scale effect to file grid items
- Add transition-all to list view items for consistent behavior
- Zero JavaScript overhead, pure CSS transitions
- All animations under 200ms for instant feel
* feat: Add button active state and dashboard card animations
- Add active:scale-95 to all buttons for tactile click feedback
- Add hover border effect to dashboard cards (150ms transition)
- Add pulse animation to dashboard loading states
- Pure CSS transitions with zero JavaScript overhead
- Improves enterprise-level feel of UI
* feat: Add smooth macOS-style page transitions
- Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in)
- Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile)
- Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json
- Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior
All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX
* fix: Add key prop to force animation re-trigger on tab switch
Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation
* revert: Remove page transition animations
Page switching animations were not noticeable enough and felt unnecessary.
Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes
* feat: Add ripple effect to login/logout transitions
Add three-layer expanding ripple animation during fadeOut phase:
- Ripples expand from screen center using primary theme color
- Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect
- Ripples fade out as they expand to create elegant visual feedback
- Uses pure CSS keyframe animation, no external libraries
Total animation: 800ms ripple + 300ms screen fade
* feat: Add smooth TERMIX logo animation to transitions
Changes:
- Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel
- Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety
- Slow down ripple animation from 0.8s to 2s with cubic-bezier easing
- Add centered TERMIX logo with monospace font and subtitle
- Logo fades in from 80% scale, holds, then fades out at 110% scale
- Total effect: 1.2s logo animation synced with 2s ripple waves
Creates a premium, branded transition experience
* feat: Enhance transition animation with premium details
Timing adjustments:
- Extend fadeOut from 800ms to 1200ms
- Extend fadeIn from 600ms to 800ms
- Slow background fade to 700ms for elegance
Visual enhancements:
- Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays
- Ripple animation extended to 2.5s with refined opacity curve
- Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px
- Logo glow effect: triple-layer text-shadow using primary theme color
- Increase logo size from text-6xl to text-7xl
- Subtitle delayed fade-in from bottom with smooth slide animation
Creates a cinematic, polished brand experience
* feat: Redesign login page with split-screen cinematic layout
Major redesign of authentication page:
Left Side (40% width):
- Full-height gradient background using primary theme color
- Large TERMIX logo with glow effect
- Subtitle and tagline
- Infinite animated ripple waves (3 layers)
- Hidden on mobile, shows brand identity
Right Side (60% width):
- Centered glassmorphism card with backdrop blur
- Refined tab switcher with pill-style active state
- Enlarged title with gradient text effect
- Added welcome subtitles for better UX
- Card slides in from bottom on load
- All existing functionality preserved
Visual enhancements:
- Tab navigation: segmented control style in muted container
- Active tab: white background with subtle shadow
- Smooth 200ms transitions on all interactions
- Card: rounded-2xl, shadow-xl, semi-transparent border
Creates premium, modern login experience matching transition animations
* feat: Update login page theme colors and add i18n support
- Changed login page gradient from blue to match dark theme colors
- Updated ripple effects to use theme primary color
- Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal)
- Updated all language files (en, zh, de, ru, pt-BR) with new translations
- Fixed TypeScript compilation issues by clearing build cache
* refactor: Use shadcn Tabs component and fix modal styling
- Replace custom tab navigation with shadcn Tabs component
- Restore border-2 border-dark-border for modal consistency
- Remove circular icon from login success message
- Simplify authentication success display
* refactor: Remove ripple effects and gradient from login page
- Remove animated ripple background effects
- Remove gradient background, use solid color (bg-dark-bg-darker)
- Remove text-shadow glow effect from logo
- Simplify brand showcase to clean, minimal design
* feat: Add decorative slash and remove subtitle from login page
- Add decorative slash divider with gradient lines below TERMIX logo
- Remove subtitle text (welcomeBack and createAccount)
- Simplify page title to show only the main heading
* feat: Add diagonal line pattern background to login page
- Replace decorative slash with subtle diagonal line pattern background
- Use repeating-linear-gradient at 45deg angle
- Set very low opacity (0.03) for subtle effect
- Pattern uses theme primary color
* fix: Display diagonal line pattern on login background
- Combine background color and pattern in single style attribute
- Use white semi-transparent lines (rgba 0.03 opacity)
- 45deg angle, 35px spacing, 2px width
- Remove separate overlay div to ensure pattern visibility
* security: Fix user enumeration vulnerability in login
- Unify error messages for invalid username and incorrect password
- Both return 401 status with 'Invalid username or password'
- Prevent attackers from enumerating valid usernames
- Maintain detailed logging for debugging purposes
- Changed from 404 'User not found' to generic auth failure message
* security: Add login rate limiting to prevent brute force attacks
- Implement LoginRateLimiter with IP and username-based tracking
- Block after 5 failed attempts within 15 minutes
- Lock account/IP for 15 minutes after threshold
- Automatic cleanup of expired entries every 5 minutes
- Track remaining attempts in logs for monitoring
- Return 429 status with remaining time on rate limit
- Reset counters on successful login
- Dual protection: both IP-based and username-based limits
1118 lines
38 KiB
TypeScript
1118 lines
38 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { cn } from "@/lib/utils.ts";
|
|
import { Button } from "@/components/ui/button.tsx";
|
|
import { Input } from "@/components/ui/input.tsx";
|
|
import { Label } from "@/components/ui/label.tsx";
|
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
|
|
import { toast } from "sonner";
|
|
import { Smartphone } from "lucide-react";
|
|
import {
|
|
registerUser,
|
|
loginUser,
|
|
getUserInfo,
|
|
getRegistrationAllowed,
|
|
getPasswordLoginAllowed,
|
|
getOIDCConfig,
|
|
getSetupRequired,
|
|
initiatePasswordReset,
|
|
verifyPasswordResetCode,
|
|
completePasswordReset,
|
|
getOIDCAuthorizeUrl,
|
|
verifyTOTPLogin,
|
|
logoutUser,
|
|
isElectron,
|
|
getCookie,
|
|
} from "@/ui/main-axios.ts";
|
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
|
|
|
function isReactNativeWebView(): boolean {
|
|
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
|
}
|
|
|
|
function postJWTToWebView() {
|
|
if (!isReactNativeWebView()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
|
|
|
|
if (!jwt) {
|
|
return;
|
|
}
|
|
|
|
(window as any).ReactNativeWebView.postMessage(
|
|
JSON.stringify({
|
|
type: "AUTH_SUCCESS",
|
|
token: jwt,
|
|
source: "explicit",
|
|
platform: "mobile",
|
|
timestamp: Date.now(),
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.error("Auth operation failed:", error);
|
|
}
|
|
}
|
|
|
|
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;
|
|
dbError: string | null;
|
|
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,
|
|
dbError,
|
|
setDbError,
|
|
onAuthSuccess,
|
|
...props
|
|
}: AuthProps) {
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
|
|
"login",
|
|
);
|
|
const [localUsername, setLocalUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [oidcLoading, setOidcLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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 [oidcConfigured, setOidcConfigured] = 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 [mobileAuthSuccess, setMobileAuthSuccess] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setInternalLoggedIn(loggedIn);
|
|
}, [loggedIn]);
|
|
|
|
useEffect(() => {
|
|
getRegistrationAllowed().then((res) => {
|
|
setRegistrationAllowed(res.allowed);
|
|
});
|
|
}, []);
|
|
|
|
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(() => {
|
|
if (!registrationAllowed && !internalLoggedIn) {
|
|
toast.warning(t("messages.registrationDisabled"));
|
|
}
|
|
}, [registrationAllowed, internalLoggedIn, t]);
|
|
|
|
useEffect(() => {
|
|
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
|
setTab("external");
|
|
}
|
|
}, [passwordLoginAllowed, oidcConfigured, tab]);
|
|
|
|
useEffect(() => {
|
|
getOIDCConfig()
|
|
.then((response) => {
|
|
if (response) {
|
|
setOidcConfigured(true);
|
|
} else {
|
|
setOidcConfigured(false);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (error.response?.status === 404) {
|
|
setOidcConfigured(false);
|
|
} else {
|
|
setOidcConfigured(false);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
})
|
|
.catch(() => {
|
|
setDbError(t("errors.databaseConnection"));
|
|
});
|
|
}, [setDbError, firstUserToastShown]);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
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);
|
|
} 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);
|
|
}
|
|
|
|
if (res.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpTempToken(res.temp_token);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!res || !res.success) {
|
|
throw new Error(t("errors.loginFailed"));
|
|
}
|
|
|
|
const [meRes] = await Promise.all([getUserInfo()]);
|
|
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
setDbError(null);
|
|
postJWTToWebView();
|
|
|
|
if (isReactNativeWebView()) {
|
|
setMobileAuthSuccess(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoggedIn(true);
|
|
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");
|
|
setError(errorMessage);
|
|
setInternalLoggedIn(false);
|
|
setLoggedIn(false);
|
|
setIsAdmin(false);
|
|
setUsername(null);
|
|
setUserId(null);
|
|
if (error?.response?.data?.error?.includes("Database")) {
|
|
setDbError(t("errors.databaseConnection"));
|
|
} else {
|
|
setDbError(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleInitiatePasswordReset() {
|
|
setError(null);
|
|
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 } };
|
|
};
|
|
const errorMessage =
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("errors.failedPasswordReset");
|
|
setError(errorMessage);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleVerifyResetCode() {
|
|
setError(null);
|
|
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 } } };
|
|
const errorMessage =
|
|
error?.response?.data?.error || t("errors.failedVerifyCode");
|
|
setError(errorMessage);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCompletePasswordReset() {
|
|
setError(null);
|
|
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("");
|
|
setError(null);
|
|
|
|
setResetSuccess(true);
|
|
toast.success(t("messages.passwordResetSuccess"));
|
|
|
|
setTab("login");
|
|
resetPasswordState();
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { error?: string } } };
|
|
const errorMessage =
|
|
error?.response?.data?.error || t("errors.failedCompleteReset");
|
|
setError(errorMessage);
|
|
} finally {
|
|
setResetLoading(false);
|
|
}
|
|
}
|
|
|
|
function resetPasswordState() {
|
|
setResetStep("initiate");
|
|
setResetCode("");
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setTempToken("");
|
|
setError(null);
|
|
setResetSuccess(false);
|
|
setSignupConfirmPassword("");
|
|
}
|
|
|
|
function clearFormFields() {
|
|
setPassword("");
|
|
setSignupConfirmPassword("");
|
|
setError(null);
|
|
}
|
|
|
|
async function handleTOTPVerification() {
|
|
if (totpCode.length !== 6) {
|
|
toast.error(t("auth.enterCode"));
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setTotpLoading(true);
|
|
|
|
try {
|
|
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
|
|
|
if (!res || !res.success) {
|
|
throw new Error(t("errors.loginFailed"));
|
|
}
|
|
|
|
if (isElectron() && res.token) {
|
|
localStorage.setItem("jwt", res.token);
|
|
}
|
|
|
|
setIsAdmin(!!res.is_admin);
|
|
setUsername(res.username || null);
|
|
setUserId(res.userId || null);
|
|
setDbError(null);
|
|
|
|
postJWTToWebView();
|
|
|
|
if (isReactNativeWebView()) {
|
|
setMobileAuthSuccess(true);
|
|
setTotpLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoggedIn(true);
|
|
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 } };
|
|
};
|
|
const errorCode = error?.response?.data?.code;
|
|
const 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 {
|
|
setError(errorMessage);
|
|
}
|
|
} finally {
|
|
setTotpLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleOIDCLogin() {
|
|
setError(null);
|
|
setOidcLoading(true);
|
|
try {
|
|
const authResponse = await getOIDCAuthorizeUrl();
|
|
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");
|
|
setError(errorMessage);
|
|
setOidcLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const success = urlParams.get("success");
|
|
const error = urlParams.get("error");
|
|
|
|
if (error) {
|
|
const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
|
|
setError(errorMessage);
|
|
setOidcLoading(false);
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
return;
|
|
}
|
|
|
|
if (success) {
|
|
setOidcLoading(true);
|
|
setError(null);
|
|
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
|
|
setTimeout(() => {
|
|
getUserInfo()
|
|
.then((meRes) => {
|
|
setIsAdmin(!!meRes.is_admin);
|
|
setUsername(meRes.username || null);
|
|
setUserId(meRes.userId || null);
|
|
setDbError(null);
|
|
postJWTToWebView();
|
|
|
|
if (isReactNativeWebView()) {
|
|
setMobileAuthSuccess(true);
|
|
setOidcLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoggedIn(true);
|
|
onAuthSuccess({
|
|
isAdmin: !!meRes.is_admin,
|
|
username: meRes.username || null,
|
|
userId: meRes.userId || null,
|
|
});
|
|
|
|
setInternalLoggedIn(true);
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to get user info after OIDC callback:", err);
|
|
setError(t("errors.failedUserInfo"));
|
|
setInternalLoggedIn(false);
|
|
setLoggedIn(false);
|
|
setIsAdmin(false);
|
|
setUsername(null);
|
|
setUserId(null);
|
|
})
|
|
.finally(() => {
|
|
setOidcLoading(false);
|
|
});
|
|
}, 200);
|
|
}
|
|
}, []);
|
|
|
|
const Spinner = (
|
|
<svg
|
|
className="animate-spin mr-2 h-4 w-4 text-white 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>
|
|
);
|
|
|
|
if (isReactNativeWebView() && mobileAuthSuccess) {
|
|
return (
|
|
<div
|
|
className={`w-full max-w-md flex flex-col bg-dark-bg overflow-y-auto my-2 ${className || ""}`}
|
|
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
|
{...props}
|
|
>
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4 px-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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`w-full max-w-md flex flex-col bg-dark-bg overflow-y-auto my-2 ${className || ""}`}
|
|
style={{ maxHeight: "calc(100vh - 1rem)" }}
|
|
{...props}
|
|
>
|
|
{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>
|
|
)}
|
|
{!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>
|
|
)}
|
|
{!mobileAuthSuccess && totpRequired && (
|
|
<div className="flex flex-col gap-5">
|
|
<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
|
|
id="totp-code"
|
|
type="text"
|
|
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="button"
|
|
className="w-full h-11 text-base font-semibold"
|
|
disabled={totpLoading || totpCode.length < 6}
|
|
onClick={handleTOTPVerification}
|
|
>
|
|
{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("");
|
|
setError(null);
|
|
}}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!mobileAuthSuccess && internalLoggedIn && !authLoading && (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="mb-6 text-center">
|
|
<h2 className="text-xl font-bold mb-1">
|
|
{t("homepage.loggedInTitle")}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{t("mobile.mobileAppInProgressDesc")}
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
onClick={() =>
|
|
window.open("https://docs.termix.site/install", "_blank")
|
|
}
|
|
>
|
|
{t("mobile.viewMobileAppDocs")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!mobileAuthSuccess &&
|
|
!internalLoggedIn &&
|
|
!authLoading &&
|
|
!totpRequired && (
|
|
<>
|
|
{(() => {
|
|
const hasLogin = passwordLoginAllowed && !firstUser;
|
|
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>
|
|
);
|
|
}
|
|
|
|
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
|
|
type="button"
|
|
className={cn(
|
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
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}
|
|
>
|
|
{t("common.register")}
|
|
</button>
|
|
)}
|
|
{oidcConfigured && (
|
|
<button
|
|
type="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"
|
|
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>
|
|
) : (
|
|
<form
|
|
className="flex flex-col gap-5"
|
|
onSubmit={handleSubmit}
|
|
>
|
|
<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 || internalLoggedIn}
|
|
/>
|
|
</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 || internalLoggedIn}
|
|
/>
|
|
</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 || internalLoggedIn}
|
|
/>
|
|
</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 || internalLoggedIn}
|
|
onClick={() => {
|
|
setTab("reset");
|
|
resetPasswordState();
|
|
clearFormFields();
|
|
}}
|
|
>
|
|
{t("auth.resetPasswordButton")}
|
|
</Button>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
<div className="mt-6 pt-4 border-t border-dark-border">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm text-muted-foreground">
|
|
{t("common.language")}
|
|
</Label>
|
|
</div>
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full h-11 text-base font-semibold"
|
|
onClick={() =>
|
|
window.open(
|
|
"https://docs.termix.site/install",
|
|
"_blank",
|
|
)
|
|
}
|
|
>
|
|
{t("mobile.viewMobileAppDocs")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|