v1.6.0 #221
@@ -1,16 +1,46 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {Menu} from "lucide-react";
|
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 {
|
interface MenuProps {
|
||||||
onSidebarOpenClick?: () => void;
|
onSidebarOpenClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
||||||
|
const {tabs, currentTab, setCurrentTab, removeTab} = useTabs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[80px] bg-[#18181BFF] flex flex-col justify-center">
|
<div className="w-full h-[80px] bg-[#18181B] flex items-center p-2 gap-2">
|
||||||
<Button className="w-[40px] h-[40px] ml-2" variant="outline" onClick={onSidebarOpenClick}>
|
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||||
<Menu />
|
<Menu/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap thin-scrollbar">
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-10 rounded-r-none !px-3 border-1 border-[#303032]",
|
||||||
|
tab.id === currentTab && '!bg-[#09090b] !text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab(tab.id)}
|
||||||
|
>
|
||||||
|
<TerminalIcon className="mr-1 h-4 w-4"/>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 rounded-l-none !px-2 border-1 border-[#303032]"
|
||||||
|
onClick={() => removeTab(tab.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ interface SSHHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FolderCardProps {
|
interface FolderCardProps {
|
||||||
folderName: string,
|
folderName: string;
|
||||||
hosts: SSHHost[],
|
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 [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
const toggleExpanded = () => {
|
||||||
@@ -64,7 +65,7 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
|
|||||||
<div className="flex flex-col p-2 gap-y-3">
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
{hosts.map((host, index) => (
|
{hosts.map((host, index) => (
|
||||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
<Host host={host}/>
|
<Host host={host} onHostConnect={onHostConnect}/>
|
||||||
|
|
||||||
{index < hosts.length - 1 && (
|
{index < hosts.length - 1 && (
|
||||||
<div className="relative -mx-2">
|
<div className="relative -mx-2">
|
||||||
@@ -77,4 +78,4 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
|
|||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -30,9 +31,11 @@ interface SSHHost {
|
|||||||
|
|
||||||
interface HostProps {
|
interface HostProps {
|
||||||
host: SSHHost;
|
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 [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
const hasTags = tags.length > 0;
|
const hasTags = tags.length > 0;
|
||||||
@@ -65,11 +68,8 @@ export function Host({host}: HostProps): React.ReactElement {
|
|||||||
}, [host.id]);
|
}, [host.id]);
|
||||||
|
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = () => {
|
||||||
|
addTab({type: 'terminal', title, hostConfig: host});
|
||||||
};
|
onHostConnect();
|
||||||
|
|
||||||
const handleServerClick = () => {
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 {Button} from "@/components/ui/button.tsx";
|
||||||
import {Menu} from "lucide-react";
|
import {ChevronUp, Menu, User2} from "lucide-react";
|
||||||
import React from "react";
|
import React, {useState, useEffect, useMemo, useCallback} from "react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.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 {
|
interface LeftSidebarProps {
|
||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
setIsSidebarOpen: (type: boolean) => void;
|
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<SSHHost[]>([]);
|
||||||
|
const [hostsLoading, setHostsLoading] = useState(false);
|
||||||
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||||
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||||
|
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<string, SSHHost[]> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<SidebarProvider open={isSidebarOpen}>
|
<SidebarProvider open={isSidebarOpen}>
|
||||||
@@ -30,32 +153,73 @@ export function LeftSidebar({ isSidebarOpen, setIsSidebarOpen }: LeftSidebarProp
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<SidebarContent className="px-2 py-2">
|
<SidebarContent className="px-2 py-2">
|
||||||
<FolderCard
|
<div className="!bg-[#222225] rounded-lg mb-2">
|
||||||
folderName="Folder"
|
<Input
|
||||||
hosts={[{
|
value={search}
|
||||||
id: 1,
|
onChange={e => setSearch(e.target.value)}
|
||||||
name: "My Server",
|
placeholder={t('placeholders.searchHostsAny')}
|
||||||
ip: "192.168.1.100",
|
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||||
port: 22,
|
autoComplete="off"
|
||||||
username: "admin",
|
/>
|
||||||
folder: "/home/admin",
|
</div>
|
||||||
tags: ["production", "backend"],
|
|
||||||
pin: true,
|
{hostsError && (
|
||||||
authType: "password",
|
<div className="px-1">
|
||||||
password: "securePassword123",
|
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
key: undefined,
|
{t('leftSidebar.failedToLoadHosts')}
|
||||||
keyPassword: undefined,
|
</div>
|
||||||
keyType: undefined,
|
</div>
|
||||||
enableTerminal: true,
|
)}
|
||||||
enableTunnel: false,
|
|
||||||
enableFileManager: true,
|
{hostsLoading && (
|
||||||
defaultPath: "/home/admin/projects",
|
<div className="px-4 pb-2">
|
||||||
tunnelConnections: [],
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
createdAt: "2025-09-05T12:00:00Z",
|
{t('hosts.loadingHosts')}
|
||||||
updatedAt: "2025-09-05T12:00:00Z"
|
</div>
|
||||||
}]}
|
</div>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
|
{sortedFolders.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={`folder-${folder}`}
|
||||||
|
folderName={folder}
|
||||||
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
|
onHostConnect={onHostConnect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<Separator className="mt-1"/>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="data-[state=open]:opacity-90 w-full"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<User2/> {username ? username : t('common.logout')}
|
||||||
|
<ChevronUp className="ml-auto"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={handleLogout}>
|
||||||
|
|
||||||
|
<span>{t('common.logout')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
100
src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx
Normal file
100
src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx
Normal file
@@ -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<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabContextType {
|
||||||
|
tabs: Tab[];
|
||||||
|
currentTab: number | null;
|
||||||
|
addTab: (tab: Omit<Tab, 'id'>) => number;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
|
setCurrentTab: (tabId: number) => void;
|
||||||
|
getTab: (tabId: number) => Tab | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabContext = createContext<TabContextType | undefined>(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<Tab[]>([]);
|
||||||
|
const [currentTab, setCurrentTab] = useState<number | null>(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<Tab, 'id'>): number => {
|
||||||
|
const id = nextTabId.current++;
|
||||||
|
const newTab: Tab = {
|
||||||
|
...tabData,
|
||||||
|
id,
|
||||||
|
title: computeUniqueTitle(tabData.title),
|
||||||
|
terminalRef: React.createRef<any>()
|
||||||
|
};
|
||||||
|
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 (
|
||||||
|
<TabContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TabContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,14 +6,6 @@ import {Unicode11Addon} from '@xterm/addon-unicode11';
|
|||||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
mobileTerminalInitialized?: boolean;
|
|
||||||
mobileTerminalWebSocket?: WebSocket | null;
|
|
||||||
mobileTerminalPingInterval?: NodeJS.Timeout | null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHTerminalProps {
|
interface SSHTerminalProps {
|
||||||
hostConfig: any;
|
hostConfig: any;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -33,9 +25,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
const lastHostConfigRef = useRef<any>(null);
|
|
||||||
const isInitializedRef = useRef<boolean>(false);
|
|
||||||
const terminalInstanceRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
@@ -132,13 +121,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
ws.send(JSON.stringify({type: 'input', data}));
|
ws.send(JSON.stringify({type: 'input', data}));
|
||||||
});
|
});
|
||||||
|
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({type: 'ping'}));
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
window.mobileTerminalPingInterval = pingIntervalRef.current;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
@@ -160,7 +148,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('error', () => {
|
ws.addEventListener('error', () => {
|
||||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||||
});
|
});
|
||||||
@@ -169,70 +157,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
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 = {
|
terminal.options = {
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'bar',
|
cursorStyle: 'bar',
|
||||||
@@ -291,18 +215,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' &&
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
(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 isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||||
|
|
||||||
const wsUrl = isDev
|
const wsUrl = isDev
|
||||||
? 'ws://localhost:8082'
|
? 'ws://localhost:8082'
|
||||||
: isElectron
|
: isElectron
|
||||||
? 'ws://127.0.0.1:8082'
|
? 'ws://127.0.0.1:8082'
|
||||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
window.mobileTerminalWebSocket = ws;
|
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
setupWebSocketListeners(ws, cols, rows);
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
@@ -313,6 +236,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
|
if (pingIntervalRef.current) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
webSocketRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]);
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
|
|
||||||
|
|||||||
798
src/ui/Mobile/Homepage/HomepageAuth.tsx
Normal file
798
src/ui/Mobile/Homepage/HomepageAuth.tsx
Normal file
@@ -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<string | null>(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 = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full max-w-md flex flex-col bg-[#18181b] ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{dbError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{dbError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{firstUser && !dbError && !internalLoggedIn && (
|
||||||
|
<Alert variant="default" className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
|
||||||
|
<AlertDescription className="inline">
|
||||||
|
{t('auth.firstUserMessage')}{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||||
|
>
|
||||||
|
GitHub Issue
|
||||||
|
</a>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!registrationAllowed && !internalLoggedIn && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('messages.registrationDisabled')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<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>
|
||||||
|
<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 || !registrationAllowed}
|
||||||
|
>
|
||||||
|
{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" && (
|
||||||
|
<>
|
||||||
|
<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" && (
|
||||||
|
<>o
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetSuccess && (
|
||||||
|
<>
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('auth.passwordResetSuccessDesc')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
resetPasswordState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.goToLogin')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="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>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="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>
|
||||||
|
<Input id="password" type="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>
|
||||||
|
<Input id="signup-confirm-password" type="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-[#303032]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mt-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
||||||
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
||||||
import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx";
|
import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx";
|
||||||
import {LeftSidebar} from "@/ui/Mobile/Apps/Navigation/LeftSidebar.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 = () => {
|
function getCookie(name: string) {
|
||||||
const terminalRef = useRef<any>(null);
|
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 [isSidebarOpen, setIsSidebarOpen] = React.useState(false);
|
||||||
|
const [ready, setReady] = React.useState(true);
|
||||||
|
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(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) {
|
function handleKeyboardInput(input: string) {
|
||||||
if (!terminalRef.current?.sendInput) return;
|
const currentTerminalTab = getTab(currentTab as number);
|
||||||
|
if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) {
|
||||||
const keyMap: Record<string, string> = {
|
currentTerminalTab.terminalRef.current.sendInput(input);
|
||||||
"{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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-[#09090b]">
|
||||||
|
<p className="text-white">Loading...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-[#18181b] p-4">
|
||||||
|
<HomepageAuth
|
||||||
|
setLoggedIn={setIsAuthenticated}
|
||||||
|
setIsAdmin={setIsAdmin}
|
||||||
|
setUsername={setUsername}
|
||||||
|
setUserId={(id) => {
|
||||||
|
}}
|
||||||
|
loggedIn={isAuthenticated}
|
||||||
|
authLoading={authLoading}
|
||||||
|
dbError={null}
|
||||||
|
setDbError={(err) => {
|
||||||
|
}}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden relative">
|
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden relative">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 relative">
|
||||||
<Terminal
|
{tabs.map(tab => (
|
||||||
ref={terminalRef}
|
<div
|
||||||
hostConfig={{
|
key={tab.id}
|
||||||
ip: "n/a",
|
className="absolute inset-0"
|
||||||
port: 22,
|
style={{
|
||||||
username: "n/a",
|
visibility: tab.id === currentTab ? 'visible' : 'hidden',
|
||||||
password: "n/a"
|
opacity: ready ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
isVisible={true}
|
>
|
||||||
/>
|
<Terminal
|
||||||
|
ref={tab.terminalRef}
|
||||||
|
hostConfig={tab.hostConfig}
|
||||||
|
isVisible={tab.id === currentTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tabs.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-full text-white">
|
||||||
|
Select a host to start a terminal session.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<TerminalKeyboard
|
{currentTab && <TerminalKeyboard onSendInput={handleKeyboardInput}/>}
|
||||||
onSendInput={handleKeyboardInput}
|
|
||||||
/>
|
|
||||||
<BottomNavbar
|
<BottomNavbar
|
||||||
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -64,13 +158,26 @@ export const MobileApp: FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
||||||
<div onClick={(e) => { e.stopPropagation(); }} className="pointer-events-auto">
|
<div onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}} className="pointer-events-auto">
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
isSidebarOpen={isSidebarOpen}
|
isSidebarOpen={isSidebarOpen}
|
||||||
setIsSidebarOpen={setIsSidebarOpen}
|
setIsSidebarOpen={setIsSidebarOpen}
|
||||||
|
onHostConnect={closeSidebar}
|
||||||
|
disabled={!isAuthenticated || authLoading}
|
||||||
|
username={username}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileApp: FC = () => {
|
||||||
|
return (
|
||||||
|
<TabProvider>
|
||||||
|
<AppContent/>
|
||||||
|
</TabProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user