diff --git a/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx b/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx index 3016c11f..82c9b7aa 100644 --- a/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx +++ b/src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx @@ -1,16 +1,46 @@ -import { Button } from "@/components/ui/button"; -import {Menu} from "lucide-react"; +import {Button} from "@/components/ui/button"; +import {Menu, X, Terminal as TerminalIcon} from "lucide-react"; +import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; +import {cn} from "@/lib/utils.ts"; interface MenuProps { onSidebarOpenClick?: () => void; } export function BottomNavbar({onSidebarOpenClick}: MenuProps) { + const {tabs, currentTab, setCurrentTab, removeTab} = useTabs(); + return ( -
- +
+
+ {tabs.map(tab => ( +
+ + +
+ ))} +
+
) -} \ No newline at end of file +} diff --git a/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx b/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx index 611b24b9..9e63ddbc 100644 --- a/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx +++ b/src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx @@ -29,11 +29,12 @@ interface SSHHost { } interface FolderCardProps { - folderName: string, - hosts: SSHHost[], + folderName: string; + hosts: SSHHost[]; + onHostConnect: () => void; } -export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement { +export function FolderCard({folderName, hosts, onHostConnect}: FolderCardProps): React.ReactElement { const [isExpanded, setIsExpanded] = useState(true); const toggleExpanded = () => { @@ -64,7 +65,7 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
{hosts.map((host, index) => ( - + {index < hosts.length - 1 && (
@@ -77,4 +78,4 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle )}
) -} \ No newline at end of file +} diff --git a/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx b/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx index 856176b6..8145c7d8 100644 --- a/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx +++ b/src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx @@ -4,6 +4,7 @@ import {Button} from "@/components/ui/button.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Server, Terminal} from "lucide-react"; import {getServerStatusById} from "@/ui/main-axios.ts"; +import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; interface SSHHost { id: number; @@ -30,9 +31,11 @@ interface SSHHost { interface HostProps { host: SSHHost; + onHostConnect: () => void; } -export function Host({host}: HostProps): React.ReactElement { +export function Host({host, onHostConnect}: HostProps): React.ReactElement { + const {addTab} = useTabs(); const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded'); const tags = Array.isArray(host.tags) ? host.tags : []; const hasTags = tags.length > 0; @@ -65,11 +68,8 @@ export function Host({host}: HostProps): React.ReactElement { }, [host.id]); const handleTerminalClick = () => { - - }; - - const handleServerClick = () => { - + addTab({type: 'terminal', title, hostConfig: host}); + onHostConnect(); }; return ( diff --git a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx b/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx index 45a5539d..d98b968a 100644 --- a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx +++ b/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx @@ -1,17 +1,140 @@ -import {Sidebar, SidebarContent, SidebarGroupLabel, SidebarHeader, SidebarProvider} from "@/components/ui/sidebar.tsx"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroupLabel, + SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarProvider +} from "@/components/ui/sidebar.tsx"; import {Button} from "@/components/ui/button.tsx"; -import {Menu} from "lucide-react"; -import React from "react"; +import {ChevronUp, Menu, User2} from "lucide-react"; +import React, {useState, useEffect, useMemo, useCallback} from "react"; import {Separator} from "@/components/ui/separator.tsx"; import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx"; -import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx"; +import {getSSHHosts} from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; +import {Input} from "@/components/ui/input.tsx"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; +} interface LeftSidebarProps { isSidebarOpen: boolean; setIsSidebarOpen: (type: boolean) => void; + onHostConnect: () => void; + disabled?: boolean; + username?: string | null; } -export function LeftSidebar({ isSidebarOpen, setIsSidebarOpen }: LeftSidebarProps) { +function handleLogout() { + document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + window.location.reload(); +} + +export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, disabled, username}: LeftSidebarProps) { + const {t} = useTranslation(); + const [hosts, setHosts] = useState([]); + const [hostsLoading, setHostsLoading] = useState(false); + const [hostsError, setHostsError] = useState(null); + const prevHostsRef = React.useRef([]); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + const fetchHosts = useCallback(async () => { + try { + const newHosts = await getSSHHosts(); + const prevHosts = prevHostsRef.current; + + if (JSON.stringify(newHosts) !== JSON.stringify(prevHosts)) { + setHosts(newHosts); + prevHostsRef.current = newHosts; + } + } catch (err: any) { + setHostsError(t('leftSidebar.failedToLoadHosts')); + } + }, [t]); + + useEffect(() => { + fetchHosts(); + const interval = setInterval(fetchHosts, 300000); + return () => clearInterval(interval); + }, [fetchHosts]); + + useEffect(() => { + const handleHostsChanged = () => { + fetchHosts(); + }; + window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); + return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); + }, [fetchHosts]); + + useEffect(() => { + const handler = setTimeout(() => setDebouncedSearch(search), 200); + return () => clearTimeout(handler); + }, [search]); + + const filteredHosts = useMemo(() => { + if (!debouncedSearch.trim()) return hosts; + const q = debouncedSearch.trim().toLowerCase(); + return hosts.filter(h => { + const searchableText = [ + h.name || '', + h.username, + h.ip, + h.folder || '', + ...(h.tags || []), + ].join(' ').toLowerCase(); + return searchableText.includes(q); + }); + }, [hosts, debouncedSearch]); + + const hostsByFolder = useMemo(() => { + const map: Record = {}; + filteredHosts.forEach(h => { + const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); + if (!map[folder]) map[folder] = []; + map[folder].push(h); + }); + return map; + }, [filteredHosts, t]); + + const sortedFolders = useMemo(() => { + const folders = Object.keys(hostsByFolder); + folders.sort((a, b) => { + if (a === t('leftSidebar.noFolder')) return 1; + if (b === t('leftSidebar.noFolder')) return -1; + return a.localeCompare(b); + }); + return folders; + }, [hostsByFolder, t]); + + const getSortedHosts = useCallback((arr: SSHHost[]) => { + const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + return [...pinned, ...rest]; + }, []); + return (
@@ -30,32 +153,73 @@ export function LeftSidebar({ isSidebarOpen, setIsSidebarOpen }: LeftSidebarProp - +
+ setSearch(e.target.value)} + placeholder={t('placeholders.searchHostsAny')} + className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md" + autoComplete="off" + /> +
+ + {hostsError && ( +
+
+ {t('leftSidebar.failedToLoadHosts')} +
+
+ )} + + {hostsLoading && ( +
+
+ {t('hosts.loadingHosts')} +
+
+ )} + + {sortedFolders.map((folder) => ( + + ))}
+ + + + + + + + {username ? username : t('common.logout')} + + + + + + + {t('common.logout')} + + + + + +
diff --git a/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx b/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx new file mode 100644 index 00000000..eb594d73 --- /dev/null +++ b/src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx @@ -0,0 +1,100 @@ +import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; +import {useTranslation} from 'react-i18next'; + +export interface Tab { + id: number; + type: 'terminal'; + title: string; + hostConfig?: any; + terminalRef?: React.RefObject; +} + +interface TabContextType { + tabs: Tab[]; + currentTab: number | null; + addTab: (tab: Omit) => number; + removeTab: (tabId: number) => void; + setCurrentTab: (tabId: number) => void; + getTab: (tabId: number) => Tab | undefined; +} + +const TabContext = createContext(undefined); + +export function useTabs() { + const context = useContext(TabContext); + if (context === undefined) { + throw new Error('useTabs must be used within a TabProvider'); + } + return context; +} + +interface TabProviderProps { + children: ReactNode; +} + +export function TabProvider({children}: TabProviderProps) { + const {t} = useTranslation(); + const [tabs, setTabs] = useState([]); + const [currentTab, setCurrentTab] = useState(null); + const nextTabId = useRef(1); + + function computeUniqueTitle(desiredTitle: string | undefined): string { + const baseTitle = (desiredTitle || 'Terminal').trim(); + const existingTitles = tabs.map(t => t.title); + if (!existingTitles.includes(baseTitle)) { + return baseTitle; + } + let i = 2; + while (existingTitles.includes(`${baseTitle} (${i})`)) { + i++; + } + return `${baseTitle} (${i})`; + } + + const addTab = (tabData: Omit): number => { + const id = nextTabId.current++; + const newTab: Tab = { + ...tabData, + id, + title: computeUniqueTitle(tabData.title), + terminalRef: React.createRef() + }; + setTabs(prev => [...prev, newTab]); + setCurrentTab(id); + return id; + }; + + const removeTab = (tabId: number) => { + const tab = tabs.find(t => t.id === tabId); + if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") { + tab.terminalRef.current.disconnect(); + } + + setTabs(prev => { + const newTabs = prev.filter(tab => tab.id !== tabId); + if (currentTab === tabId) { + setCurrentTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null); + } + return newTabs; + }); + }; + + const getTab = (tabId: number) => { + return tabs.find(tab => tab.id === tabId); + }; + + const value: TabContextType = { + tabs, + currentTab, + addTab, + removeTab, + setCurrentTab, + getTab, + }; + + return ( + + {children} + + ); +} diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index e4415681..af0c9c80 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -6,14 +6,6 @@ import {Unicode11Addon} from '@xterm/addon-unicode11'; import {WebLinksAddon} from '@xterm/addon-web-links'; import {useTranslation} from 'react-i18next'; -declare global { - interface Window { - mobileTerminalInitialized?: boolean; - mobileTerminalWebSocket?: WebSocket | null; - mobileTerminalPingInterval?: NodeJS.Timeout | null; - } -} - interface SSHTerminalProps { hostConfig: any; isVisible: boolean; @@ -33,9 +25,6 @@ export const Terminal = forwardRef(function SSHTerminal( const pingIntervalRef = useRef(null); const [visible, setVisible] = useState(false); const isVisibleRef = useRef(false); - const lastHostConfigRef = useRef(null); - const isInitializedRef = useRef(false); - const terminalInstanceRef = useRef(null); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -132,13 +121,12 @@ export const Terminal = forwardRef(function SSHTerminal( terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); }); - + pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000); - window.mobileTerminalPingInterval = pingIntervalRef.current; }); ws.addEventListener('message', (event) => { @@ -160,7 +148,7 @@ export const Terminal = forwardRef(function SSHTerminal( terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); } }); - + ws.addEventListener('error', () => { terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); }); @@ -169,70 +157,6 @@ export const Terminal = forwardRef(function SSHTerminal( useEffect(() => { if (!terminal || !xtermRef.current || !hostConfig) return; - if (window.mobileTerminalInitialized) { - webSocketRef.current = window.mobileTerminalWebSocket || null; - pingIntervalRef.current = window.mobileTerminalPingInterval || null; - - terminal.options = { - cursorBlink: true, - cursorStyle: 'bar', - scrollback: 10000, - fontSize: 14, - fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', - theme: {background: '#09090b', foreground: '#f7f7f7'}, - allowTransparency: true, - convertEol: true, - windowsMode: false, - macOptionIsMeta: false, - macOptionClickForcesSelection: false, - rightClickSelectsWord: false, - fastScrollModifier: 'alt', - fastScrollSensitivity: 5, - allowProposedApi: true, - }; - - const fitAddon = new FitAddon(); - const clipboardAddon = new ClipboardAddon(); - const unicode11Addon = new Unicode11Addon(); - const webLinksAddon = new WebLinksAddon(); - - fitAddonRef.current = fitAddon; - terminal.loadAddon(fitAddon); - terminal.loadAddon(clipboardAddon); - terminal.loadAddon(unicode11Addon); - terminal.loadAddon(webLinksAddon); - terminal.open(xtermRef.current); - - const resizeObserver = new ResizeObserver(() => { - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current) return; - fitAddonRef.current?.fit(); - if (terminal) scheduleNotify(terminal.cols, terminal.rows); - hardRefresh(); - }, 100); - }); - - resizeObserver.observe(xtermRef.current); - - setTimeout(() => { - fitAddon.fit(); - if (terminal) scheduleNotify(terminal.cols, terminal.rows); - hardRefresh(); - setVisible(true); - }, 100); - - return () => { - resizeObserver.disconnect(); - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - }; - } - - window.mobileTerminalInitialized = true; - isInitializedRef.current = true; - terminalInstanceRef.current = terminal; - lastHostConfigRef.current = hostConfig; - terminal.options = { cursorBlink: true, cursorStyle: 'bar', @@ -291,18 +215,17 @@ export const Terminal = forwardRef(function SSHTerminal( const isDev = process.env.NODE_ENV === 'development' && (window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); - + const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true; const wsUrl = isDev ? 'ws://localhost:8082' : isElectron - ? 'ws://127.0.0.1:8082' - : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; + ? 'ws://127.0.0.1:8082' + : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; const ws = new WebSocket(wsUrl); webSocketRef.current = ws; - window.mobileTerminalWebSocket = ws; wasDisconnectedBySSH.current = false; setupWebSocketListeners(ws, cols, rows); @@ -313,6 +236,11 @@ export const Terminal = forwardRef(function SSHTerminal( resizeObserver.disconnect(); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + webSocketRef.current?.close(); }; }, [xtermRef, terminal, hostConfig]); diff --git a/src/ui/Mobile/Homepage/HomepageAuth.tsx b/src/ui/Mobile/Homepage/HomepageAuth.tsx new file mode 100644 index 00000000..c43a6f0d --- /dev/null +++ b/src/ui/Mobile/Homepage/HomepageAuth.tsx @@ -0,0 +1,798 @@ +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 "@/components/LanguageSwitcher.tsx"; +import { + registerUser, + loginUser, + getUserInfo, + getRegistrationAllowed, + getOIDCConfig, + getUserCount, + initiatePasswordReset, + verifyPasswordResetCode, + completePasswordReset, + getOIDCAuthorizeUrl, + verifyTOTPLogin, + setCookie +} from "@/ui/main-axios.ts"; + +function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); +} + +interface HomepageAuthProps 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 HomepageAuth({ + className, + setLoggedIn, + setIsAdmin, + setUsername, + setUserId, + loggedIn, + authLoading, + dbError, + setDbError, + onAuthSuccess, + ...props + }: HomepageAuthProps) { + 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(null); + const [internalLoggedIn, setInternalLoggedIn] = useState(false); + const [firstUser, setFirstUser] = useState(false); + const [registrationAllowed, setRegistrationAllowed] = 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); + + useEffect(() => { + setInternalLoggedIn(loggedIn); + }, [loggedIn]); + + useEffect(() => { + getRegistrationAllowed().then(res => { + setRegistrationAllowed(res.allowed); + }); + }, []); + + useEffect(() => { + getOIDCConfig().then((response) => { + if (response) { + setOidcConfigured(true); + } else { + setOidcConfigured(false); + } + }).catch((error) => { + if (error.response?.status === 404) { + setOidcConfigured(false); + } else { + setOidcConfigured(false); + } + }); + }, []); + + useEffect(() => { + getUserCount().then(res => { + if (res.count === 0) { + setFirstUser(true); + setTab("signup"); + } else { + setFirstUser(false); + } + setDbError(null); + }).catch(() => { + setDbError(t('errors.databaseConnection')); + }); + }, [setDbError]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + if (!localUsername.trim()) { + setError(t('errors.requiredField')); + setLoading(false); + return; + } + + try { + let res, meRes; + if (tab === "login") { + res = await loginUser(localUsername, password); + } else { + if (password !== signupConfirmPassword) { + setError(t('errors.passwordMismatch')); + setLoading(false); + return; + } + if (password.length < 6) { + setError(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.token) { + throw new Error(t('errors.noTokenReceived')); + } + + setCookie("jwt", res.token); + [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(""); + } + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); + } catch (err: any) { + setError(err?.response?.data?.error || err?.message || t('errors.unknownError')); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + setCookie("jwt", "", -1); + if (err?.response?.data?.error?.includes("Database")) { + setDbError(t('errors.databaseConnection')); + } else { + setDbError(null); + } + } finally { + setLoading(false); + } + } + + async function handleInitiatePasswordReset() { + setError(null); + setResetLoading(true); + try { + const result = await initiatePasswordReset(localUsername); + setResetStep("verify"); + setError(null); + } catch (err: any) { + setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset')); + } finally { + setResetLoading(false); + } + } + + async function handleVerifyResetCode() { + setError(null); + setResetLoading(true); + try { + const response = await verifyPasswordResetCode(localUsername, resetCode); + setTempToken(response.tempToken); + setResetStep("newPassword"); + setError(null); + } catch (err: any) { + setError(err?.response?.data?.error || t('errors.failedVerifyCode')); + } finally { + setResetLoading(false); + } + } + + async function handleCompletePasswordReset() { + setError(null); + setResetLoading(true); + + if (newPassword !== confirmPassword) { + setError(t('errors.passwordMismatch')); + setResetLoading(false); + return; + } + + if (newPassword.length < 6) { + setError(t('errors.minLength', {min: 6})); + setResetLoading(false); + return; + } + + try { + await completePasswordReset(localUsername, tempToken, newPassword); + + setResetStep("initiate"); + setResetCode(""); + setNewPassword(""); + setConfirmPassword(""); + setTempToken(""); + setError(null); + + setResetSuccess(true); + } catch (err: any) { + setError(err?.response?.data?.error || t('errors.failedCompleteReset')); + } 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) { + setError(t('auth.enterCode')); + return; + } + + setError(null); + setTotpLoading(true); + + try { + const res = await verifyTOTPLogin(totpTempToken, totpCode); + + if (!res || !res.token) { + throw new Error(t('errors.noTokenReceived')); + } + + setCookie("jwt", res.token); + 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 + }); + setInternalLoggedIn(true); + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); + } catch (err: any) { + setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode')); + } 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: any) { + setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin')); + setOidcLoading(false); + } + } + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const success = urlParams.get('success'); + const token = urlParams.get('token'); + const error = urlParams.get('error'); + + if (error) { + setError(`${t('errors.oidcAuthFailed')}: ${error}`); + setOidcLoading(false); + window.history.replaceState({}, document.title, window.location.pathname); + return; + } + + if (success && token) { + setOidcLoading(true); + setError(null); + + setCookie("jwt", token); + getUserInfo() + .then(meRes => { + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.id || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.id || null + }); + setInternalLoggedIn(true); + window.history.replaceState({}, document.title, window.location.pathname); + }) + .catch(err => { + setError(t('errors.failedUserInfo')); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + setCookie("jwt", "", -1); + window.history.replaceState({}, document.title, window.location.pathname); + }) + .finally(() => { + setOidcLoading(false); + }); + } + }, []); + + const Spinner = ( + + + + + ); + + return ( +
+ {dbError && ( + + Error + {dbError} + + )} + {firstUser && !dbError && !internalLoggedIn && ( + + {t('auth.firstUser')} + + {t('auth.firstUserMessage')}{" "} + + GitHub Issue + . + + + )} + {!registrationAllowed && !internalLoggedIn && ( + + {t('auth.registerTitle')} + + {t('messages.registrationDisabled')} + + + )} + {totpRequired && ( +
+
+

{t('auth.twoFactorAuth')}

+

{t('auth.enterCode')}

+
+ +
+ + setTotpCode(e.target.value.replace(/\D/g, ''))} + disabled={totpLoading} + className="text-center text-2xl tracking-widest font-mono" + autoComplete="one-time-code" + /> +

+ {t('auth.backupCode')} +

+
+ + + + +
+ )} + + {(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && ( + <> +
+ + + {oidcConfigured && ( + + )} +
+
+

+ {tab === "login" ? t('auth.loginTitle') : + tab === "signup" ? t('auth.registerTitle') : + tab === "external" ? t('auth.loginWithExternal') : + t('auth.forgotPassword')} +

+
+ + {tab === "external" || tab === "reset" ? ( +
+ {tab === "external" && ( + <> +
+

{t('auth.loginWithExternalDesc')}

+
+ + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> +
+

{t('auth.resetCodeDesc')}

+
+
+
+ + setLocalUsername(e.target.value)} + disabled={resetLoading} + /> +
+ +
+ + )} + + {resetStep === "verify" && ( + <>o +
+

{t('auth.enterResetCode')} {localUsername}

+
+
+
+ + setResetCode(e.target.value.replace(/\D/g, ''))} + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetSuccess && ( + <> + + {t('auth.passwordResetSuccess')} + + {t('auth.passwordResetSuccessDesc')} + + + + + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

{t('auth.enterNewPassword')} {localUsername}

+
+
+
+ + setNewPassword(e.target.value)} + disabled={resetLoading} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={resetLoading} + autoComplete="new-password" + /> +
+ + +
+ + )} + + )} +
+ ) : ( +
+
+ + setLocalUsername(e.target.value)} + disabled={loading || internalLoggedIn} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading || internalLoggedIn}/> +
+ {tab === "signup" && ( +
+ + setSignupConfirmPassword(e.target.value)} + disabled={loading || internalLoggedIn}/> +
+ )} + + {tab === "login" && ( + + )} +
+ )} + +
+
+
+ +
+ +
+
+ + )} + {error && ( + + Error + {error} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index ca4438e2..67bcffd1 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -1,57 +1,151 @@ -import React, {useRef, FC} from "react"; +import React, {useRef, FC, useState, useEffect} from "react"; import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx"; import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx"; import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx"; import {LeftSidebar} from "@/ui/Mobile/Apps/Navigation/LeftSidebar.tsx"; +import {TabProvider, useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; +import {getUserInfo} from "@/ui/main-axios.ts"; +import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx"; -export const MobileApp: FC = () => { - const terminalRef = useRef(null); +function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); +} + +const AppContent: FC = () => { + const {tabs, currentTab, getTab} = useTabs(); const [isSidebarOpen, setIsSidebarOpen] = React.useState(false); + const [ready, setReady] = React.useState(true); + + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [authLoading, setAuthLoading] = useState(true); + + useEffect(() => { + const checkAuth = () => { + const jwt = getCookie("jwt"); + if (jwt) { + setAuthLoading(true); + getUserInfo() + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + }) + .finally(() => setAuthLoading(false)); + } else { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + setAuthLoading(false); + } + } + + checkAuth() + + const handleStorageChange = () => checkAuth() + window.addEventListener('storage', handleStorageChange) + + return () => window.removeEventListener('storage', handleStorageChange) + }, []) + + const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { + setIsAuthenticated(true) + setIsAdmin(authData.isAdmin) + setUsername(authData.username) + } + + const fitCurrentTerminal = () => { + const tab = getTab(currentTab as number); + if (tab && tab.terminalRef?.current?.fit) { + tab.terminalRef.current.fit(); + } + }; + + React.useEffect(() => { + if (tabs.length > 0) { + setReady(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + fitCurrentTerminal(); + setReady(true); + }); + }); + } + }, [currentTab]); + + const closeSidebar = () => setIsSidebarOpen(false); function handleKeyboardInput(input: string) { - if (!terminalRef.current?.sendInput) return; - - const keyMap: Record = { - "{backspace}": "\x7f", - "{space}": " ", - "{tab}": "\t", - "{enter}": "", - "{escape}": "\x1b", - "{arrowUp}": "\x1b[A", - "{arrowDown}": "\x1b[B", - "{arrowRight}": "\x1b[C", - "{arrowLeft}": "\x1b[D", - "{delete}": "\x1b[3~", - "{home}": "\x1b[H", - "{end}": "\x1b[F", - "{pageUp}": "\x1b[5~", - "{pageDown}": "\x1b[6~" - }; - - if (input in keyMap) { - terminalRef.current.sendInput(keyMap[input]); - } else { - terminalRef.current.sendInput(input); + const currentTerminalTab = getTab(currentTab as number); + if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) { + currentTerminalTab.terminalRef.current.sendInput(input); } } + if (authLoading) { + return ( +
+

Loading...

+
+ ) + } + + if (!isAuthenticated) { + return ( +
+ { + }} + loggedIn={isAuthenticated} + authLoading={authLoading} + dbError={null} + setDbError={(err) => { + }} + onAuthSuccess={handleAuthSuccess} + /> +
+ ) + } + return (
-
- +
+ {tabs.map(tab => ( +
+ +
+ ))} + {tabs.length === 0 && ( +
+ Select a host to start a terminal session. +
+ )}
- + {currentTab && } setIsSidebarOpen(true)} /> @@ -64,13 +158,26 @@ export const MobileApp: FC = () => { )}
-
{ e.stopPropagation(); }} className="pointer-events-auto"> +
{ + e.stopPropagation(); + }} className="pointer-events-auto">
); +} + +export const MobileApp: FC = () => { + return ( + + + + ); } \ No newline at end of file