Merge remote-tracking branch 'origin/dev-1.6.0' into feature/export-json_calean
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
@@ -79,7 +79,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
.then(res => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
// Silently ignore OIDC config fetch errors - this is expected when OIDC is not configured
|
||||
console.debug('OIDC config not available:', error.message);
|
||||
});
|
||||
fetchUsers();
|
||||
}, []);
|
||||
@@ -206,7 +208,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t('admin.title')}</h1>
|
||||
<h1 className="font-bold text-lg">Admin Settings</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
|
||||
@@ -219,11 +221,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
OIDC
|
||||
{t('admin.oidc')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
{t('admin.users')}
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
@@ -244,8 +246,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure external identity provider for
|
||||
OIDC/OAuth2 authentication.</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
@@ -256,54 +259,60 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input id="client_id" value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder={t('placeholders.clientId')} required/>
|
||||
placeholder="your-client-id" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder={t('placeholders.clientSecret')} required/>
|
||||
placeholder="your-client-secret" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder={t('placeholders.authUrl')}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder={t('placeholders.redirectUrl')} required/>
|
||||
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input id="token_url" value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder={t('placeholders.tokenUrl')} required/>
|
||||
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder={t('placeholders.userIdField')} required/>
|
||||
placeholder="sub" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Input id="name_path" value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder={t('placeholders.usernameField')} required/>
|
||||
placeholder="name" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input id="scopes" value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
||||
placeholder={t('placeholders.scopes')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
|
||||
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
|
||||
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
|
||||
placeholder={t('placeholders.userinfoUrl')}/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
|
||||
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
|
||||
@@ -312,7 +321,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1"
|
||||
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
||||
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
@@ -332,20 +341,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
|
||||
<h3 className="text-lg font-semibold">User Management</h3>
|
||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
|
||||
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
||||
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -355,15 +364,15 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-900/20 dark:hover:bg-red-900/30"
|
||||
disabled={user.is_admin}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -379,18 +388,18 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
<TabsContent value="admins" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
||||
<h3 className="text-lg font-semibold">Admin Management</h3>
|
||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
||||
<h4 className="font-medium">Make User Admin</h4>
|
||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="new-admin-username" value={newAdminUsername}
|
||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
|
||||
<Button type="submit"
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{makeAdminError && (
|
||||
@@ -404,14 +413,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
|
||||
<h4 className="font-medium">Current Admins</h4>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -423,13 +432,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleRemoveAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-900/20 dark:hover:bg-orange-900/30">
|
||||
<Shield className="h-4 w-4"/>
|
||||
{t('admin.removeAdminButton')}
|
||||
Remove Admin
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { toast } from "sonner"
|
||||
import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios'
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialEditorProps {
|
||||
editingCredential?: Credential | null;
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const credentialsData = await getCredentials();
|
||||
setCredentials(credentialsData);
|
||||
|
||||
const uniqueFolders = [...new Set(
|
||||
credentialsData
|
||||
.filter(credential => credential.folder && credential.folder.trim() !== '')
|
||||
.map(credential => credential.folder)
|
||||
)].sort();
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
folder: z.string().optional(),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
authType: z.enum(['password', 'key']),
|
||||
username: z.string().min(1),
|
||||
password: z.string().optional(),
|
||||
key: z.instanceof(File).optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z.enum([
|
||||
'rsa',
|
||||
'ecdsa',
|
||||
'ed25519'
|
||||
]).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.authType === 'password') {
|
||||
if (!data.password || data.password.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('credentials.passwordRequired'),
|
||||
path: ['password']
|
||||
});
|
||||
}
|
||||
} else if (data.authType === 'key') {
|
||||
if (!data.key && !editingCredential) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('credentials.sshKeyRequired'),
|
||||
path: ['key']
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
name: editingCredential?.name || "",
|
||||
description: editingCredential?.description || "",
|
||||
folder: editingCredential?.folder || "",
|
||||
tags: editingCredential?.tags || [],
|
||||
authType: editingCredential?.authType || "password",
|
||||
username: editingCredential?.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "rsa",
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCredential) {
|
||||
const defaultAuthType = editingCredential.key ? 'key' : 'password';
|
||||
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
form.reset({
|
||||
name: editingCredential.name || "",
|
||||
description: editingCredential.description || "",
|
||||
folder: editingCredential.folder || "",
|
||||
tags: editingCredential.tags || [],
|
||||
authType: defaultAuthType as 'password' | 'key',
|
||||
username: editingCredential.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: (editingCredential.keyType as any) || "rsa",
|
||||
});
|
||||
} else {
|
||||
setAuthTab('password');
|
||||
|
||||
form.reset({
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "rsa",
|
||||
});
|
||||
}
|
||||
}, [editingCredential, form]);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
const formData = data as FormData;
|
||||
|
||||
if (!formData.name || formData.name.trim() === '') {
|
||||
formData.name = formData.username;
|
||||
}
|
||||
|
||||
if (editingCredential) {
|
||||
await updateCredential(editingCredential.id, formData);
|
||||
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
|
||||
} else {
|
||||
await createCredential(formData);
|
||||
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToSaveCredential'));
|
||||
}
|
||||
};
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const folderValue = form.watch('folder');
|
||||
const filteredFolders = React.useMemo(() => {
|
||||
if (!folderValue) return folders;
|
||||
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
|
||||
}, [folderValue, folders]);
|
||||
|
||||
const handleFolderClick = (folder: string) => {
|
||||
form.setValue('folder', folder);
|
||||
setFolderDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
folderDropdownRef.current &&
|
||||
!folderDropdownRef.current.contains(event.target as Node) &&
|
||||
folderInputRef.current &&
|
||||
!folderInputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setFolderDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: 'rsa', label: t('credentials.keyTypeRSA') },
|
||||
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') },
|
||||
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') },
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownOpen &&
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">{t('credentials.general')}</TabsTrigger>
|
||||
<TabsTrigger value="authentication">{t('credentials.authentication')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">{t('credentials.basicInformation')}</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t('credentials.credentialName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('placeholders.credentialName')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t('credentials.username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('placeholders.username')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('credentials.organization')}</FormLabel>
|
||||
<div className="grid grid-cols-26 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10">
|
||||
<FormLabel>{t('credentials.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('placeholders.description')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folder"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>{t('credentials.folder')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={folderInputRef}
|
||||
placeholder={t('placeholders.folder')}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={field.value}
|
||||
onFocus={() => setFolderDropdownOpen(true)}
|
||||
onChange={e => {
|
||||
field.onChange(e);
|
||||
setFolderDropdownOpen(true);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||
<div
|
||||
ref={folderDropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{filteredFolders.map((folder) => (
|
||||
<Button
|
||||
key={folder}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => handleFolderClick(folder)}
|
||||
>
|
||||
{folder}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>{t('credentials.tags')}</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
{field.value.map((tag: string, idx: number) => (
|
||||
<span key={tag + idx}
|
||||
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
||||
onClick={() => {
|
||||
const newTags = field.value.filter((_: string, i: number) => i !== idx);
|
||||
field.onChange(newTags);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === " " && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
if (!field.value.includes(tagInput.trim())) {
|
||||
field.onChange([...field.value, tagInput.trim()]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
|
||||
field.onChange(field.value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={t('credentials.addTagsSpaceToAdd')}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="authentication">
|
||||
<FormLabel className="mb-3 font-bold">{t('credentials.authentication')}</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
setAuthTab(value as 'password' | 'key');
|
||||
form.setValue('authType', value as 'password' | 'key');
|
||||
// Clear other auth fields when switching
|
||||
if (value === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
} else if (value === 'key') {
|
||||
form.setValue('password', '');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">{t('credentials.password')}</TabsTrigger>
|
||||
<TabsTrigger value="key">{t('credentials.key')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('credentials.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="key">
|
||||
<div className="grid grid-cols-15 gap-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
||||
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative min-w-0">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
||||
>
|
||||
<span className="block w-full truncate"
|
||||
title={field.value?.name || t('credentials.upload')}>
|
||||
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('placeholders.keyPassword')}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>{t('credentials.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
|
||||
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
|
||||
>
|
||||
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25"/>
|
||||
<Button
|
||||
className=""
|
||||
type="submit"
|
||||
variant="outline"
|
||||
style={{
|
||||
transform: 'translateY(8px)'
|
||||
}}
|
||||
>
|
||||
{editingCredential ? t('credentials.updateCredential') : t('credentials.addCredential')}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
482
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
482
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Settings,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialWithSecrets extends Credential {
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
}
|
||||
|
||||
interface HostInfo {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CredentialViewerProps {
|
||||
credential: Credential;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
||||
const { t } = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentialDetails();
|
||||
fetchHostsUsing();
|
||||
}, [credential.id]);
|
||||
|
||||
const fetchCredentialDetails = async () => {
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credential details:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHostsUsing = async () => {
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hosts using credential:', error);
|
||||
toast.error(t('credentials.failedToFetchHostsUsing'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSensitiveVisibility = (field: string) => {
|
||||
setShowSensitive(prev => ({
|
||||
...prev,
|
||||
[field]: !prev[field]
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t('copiedToClipboard', { field: fieldName }));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToCopy'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === 'password' ? (
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
);
|
||||
};
|
||||
|
||||
const renderSensitiveField = (
|
||||
value: string | undefined,
|
||||
fieldName: string,
|
||||
label: string,
|
||||
isMultiline = false
|
||||
) => {
|
||||
if (!value) return null;
|
||||
|
||||
const isVisible = showSensitive[fieldName];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
{isVisible ? (
|
||||
<pre className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{'•'.repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">{credentialDetails.name}</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge variant="secondary" className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t('credentials.overview')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('security')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t('credentials.security')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t('credentials.usage')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||
<div className="font-medium">{credentialDetails.folder}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t('credentials.timesUsed')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t('credentials.securityDetails')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.securityDetailsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t('credentials.credentialSecured')}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.credentialSecuredDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.authType === 'password' && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
|
||||
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === 'key' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t('credentials.keyType')}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
||||
|
||||
{credentialDetails.keyPassword && renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
'keyPassword',
|
||||
t('credentials.keyPassphrase')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t('credentials.securityReminder')}
|
||||
</div>
|
||||
<div className="text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.securityReminderText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'usage' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
{t('credentials.editCredential')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialViewer;
|
||||
336
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
336
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
Folder,
|
||||
Edit,
|
||||
Trash2,
|
||||
Shield,
|
||||
Pin,
|
||||
Tag,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { getCredentials, deleteCredential } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {CredentialEditor} from './CredentialEditor';
|
||||
import CredentialViewer from './CredentialViewer';
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialsManagerProps {
|
||||
onEditCredential?: (credential: Credential) => void;
|
||||
}
|
||||
|
||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
setCredentials(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(t('credentials.failedToFetchCredentials'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleEdit = (credential: Credential) => {
|
||||
if (onEditCredential) {
|
||||
onEditCredential(credential);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('credentials.failedToDeleteCredential'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const filteredAndSortedCredentials = useMemo(() => {
|
||||
let filtered = credentials;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = credentials.filter(credential => {
|
||||
const searchableText = [
|
||||
credential.name || '',
|
||||
credential.username,
|
||||
credential.description || '',
|
||||
...(credential.tags || []),
|
||||
credential.authType,
|
||||
credential.keyType || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [credentials, searchQuery]);
|
||||
|
||||
const credentialsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: Credential[] } = {};
|
||||
|
||||
filteredAndSortedCredentials.forEach(credential => {
|
||||
const folder = credential.folder || t('credentials.uncategorized');
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(credential);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === t('credentials.uncategorized')) return -1;
|
||||
if (b === t('credentials.uncategorized')) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGrouped: { [key: string]: Credential[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedCredentials, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">{t('credentials.loadingCredentials')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchCredentials} variant="outline">
|
||||
{t('credentials.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('credentials.noCredentialsMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
{t('credentials.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder={t('placeholders.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(credential)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{credential.name || `${credential.username}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(credential.id, credential.name || credential.username);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{credential.tags && credential.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{credential.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{credential.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-2 w-2 mr-0.5"/>
|
||||
) : (
|
||||
<Shield className="h-2 w-2 mr-0.5"/>
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === 'key' && credential.keyType && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{showViewer && viewingCredential && (
|
||||
<CredentialViewer
|
||||
credential={viewingCredential}
|
||||
onClose={() => setShowViewer(false)}
|
||||
onEdit={() => {
|
||||
setShowViewer(false);
|
||||
handleEdit(viewingCredential);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx";
|
||||
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {FileManagerOperations} from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {FIleManagerTopNavbar} from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
246
src/ui/Desktop/Apps/Host Manager/FolderManager.tsx
Normal file
246
src/ui/Desktop/Apps/Host Manager/FolderManager.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Folder,
|
||||
Edit,
|
||||
Search,
|
||||
Trash2,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FolderStats {
|
||||
name: string;
|
||||
hostCount: number;
|
||||
hosts: Array<{
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FolderManagerProps {
|
||||
onFolderChanged?: () => void;
|
||||
}
|
||||
|
||||
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [folders, setFolders] = useState<FolderStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Rename state
|
||||
const [renameLoading, setRenameLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFolders();
|
||||
}, []);
|
||||
|
||||
const fetchFolders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getFoldersWithStats();
|
||||
setFolders(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch folder statistics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (folder: FolderStats) => {
|
||||
const newName = prompt(
|
||||
`Enter new name for folder "${folder.name}":\n\nThis will update ${folder.hostCount} host(s) that use this folder.`,
|
||||
folder.name
|
||||
);
|
||||
|
||||
if (!newName || newName.trim() === '' || newName === folder.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(
|
||||
`Are you sure you want to rename folder "${folder.name}" to "${newName.trim()}"?\n\n` +
|
||||
`This will update ${folder.hostCount} host(s) that currently use this folder.`
|
||||
)) {
|
||||
try {
|
||||
setRenameLoading(true);
|
||||
await renameFolder(folder.name, newName.trim());
|
||||
toast.success(`Folder renamed from "${folder.name}" to "${newName.trim()}"`, {
|
||||
description: `Updated ${folder.hostCount} host(s)`
|
||||
});
|
||||
|
||||
// Refresh folder list
|
||||
await fetchFolders();
|
||||
|
||||
// Notify parent component about folder change
|
||||
if (onFolderChanged) {
|
||||
onFolderChanged();
|
||||
}
|
||||
|
||||
// Emit event for other components to refresh
|
||||
window.dispatchEvent(new CustomEvent('folders:changed'));
|
||||
|
||||
} catch (err) {
|
||||
toast.error('Failed to rename folder');
|
||||
} finally {
|
||||
setRenameLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return folders;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return folders.filter(folder =>
|
||||
folder.name.toLowerCase().includes(query) ||
|
||||
folder.hosts.some(host =>
|
||||
(host.name?.toLowerCase().includes(query)) ||
|
||||
host.ip.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}, [folders, searchQuery]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading folders...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchFolders} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (folders.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">No Folders Found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create some hosts with folders to manage them here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Folder Management</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredFolders.length} folder(s) found
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchFolders} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search folders..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-3 pb-20">
|
||||
{filteredFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.name}
|
||||
className="bg-[#222225] border border-input rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Folder className="h-5 w-5 text-blue-500" />
|
||||
<h3 className="font-medium text-lg truncate">
|
||||
{folder.name}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{folder.hostCount} host(s)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRename(folder)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Rename folder"
|
||||
disabled={renameLoading}
|
||||
>
|
||||
{renameLoading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<Edit className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Hosts using this folder:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-1 max-h-32 overflow-y-auto">
|
||||
{folder.hosts.slice(0, 5).map((host) => (
|
||||
<div key={host.id} className="flex items-center gap-2 text-sm bg-muted/20 rounded px-2 py-1">
|
||||
<span className="font-medium">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
{host.name && (
|
||||
<span className="text-muted-foreground">
|
||||
({host.ip})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{folder.hosts.length > 5 && (
|
||||
<div className="text-sm text-muted-foreground px-2 py-1">
|
||||
... and {folder.hosts.length - 5} more host(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, {useState} from "react";
|
||||
import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx"
|
||||
import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx";
|
||||
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
|
||||
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
@@ -38,6 +40,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
const {t} = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
@@ -50,11 +53,25 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleEditCredential = (credential: any) => {
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
};
|
||||
|
||||
const handleCredentialFormSubmit = () => {
|
||||
setEditingCredential(null);
|
||||
setActiveTab("credentials");
|
||||
};
|
||||
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value === "credentials") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
@@ -81,6 +98,10 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger>
|
||||
<TabsTrigger value="add_credential">
|
||||
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
@@ -95,6 +116,21 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<CredentialEditor
|
||||
editingCredential={editingCredential}
|
||||
onFormSubmit={handleCredentialFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area"
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx"
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
@@ -22,6 +22,7 @@ import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -44,6 +45,7 @@ interface SSHHost {
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
credentialId?: number;
|
||||
}
|
||||
|
||||
interface SSHManagerHostEditorProps {
|
||||
@@ -58,7 +60,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
||||
|
||||
// Ref for the IP address input to manage focus
|
||||
const ipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -98,7 +103,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: z.string().optional(),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
pin: z.boolean().default(false),
|
||||
authType: z.enum(['password', 'key']),
|
||||
authType: z.enum(['password', 'key', 'credential']),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
password: z.string().optional(),
|
||||
key: z.instanceof(File).optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
@@ -149,6 +155,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
path: ['keyType']
|
||||
});
|
||||
}
|
||||
} else if (data.authType === 'credential') {
|
||||
if (!data.credentialId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('hosts.credentialRequired'),
|
||||
path: ['credentialId']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.tunnelConnections.forEach((connection, index) => {
|
||||
@@ -174,7 +188,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: editingHost?.folder || "",
|
||||
tags: editingHost?.tags || [],
|
||||
pin: editingHost?.pin || false,
|
||||
authType: (editingHost?.authType as 'password' | 'key') || "password",
|
||||
authType: (editingHost?.authType as 'password' | 'key' | 'credential') || "password",
|
||||
credentialId: editingHost?.credentialId || null,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -189,7 +204,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
const defaultAuthType = editingHost.key ? 'key' : 'password';
|
||||
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
||||
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
@@ -201,7 +216,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: editingHost.folder || "",
|
||||
tags: editingHost.tags || [],
|
||||
pin: editingHost.pin || false,
|
||||
authType: defaultAuthType,
|
||||
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
||||
credentialId: editingHost.credentialId || null,
|
||||
password: editingHost.password || "",
|
||||
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
|
||||
keyPassword: editingHost.keyPassword || "",
|
||||
@@ -224,6 +240,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
tags: [],
|
||||
pin: false,
|
||||
authType: "password",
|
||||
credentialId: null,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -237,6 +254,27 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}
|
||||
}, [editingHost, form]);
|
||||
|
||||
// Focus the IP address field when the component mounts or when editingHost changes
|
||||
useEffect(() => {
|
||||
const focusTimer = setTimeout(() => {
|
||||
if (ipInputRef.current) {
|
||||
ipInputRef.current.focus();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, []); // Focus on mount
|
||||
|
||||
// Also focus when editingHost changes (for tab switching)
|
||||
useEffect(() => {
|
||||
const focusTimer = setTimeout(() => {
|
||||
if (ipInputRef.current) {
|
||||
ipInputRef.current.focus();
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [editingHost]);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
const formData = data as FormData;
|
||||
@@ -413,7 +451,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('placeholders.ipAddress')} {...field} />
|
||||
<Input
|
||||
placeholder={t('placeholders.ipAddress')}
|
||||
{...field}
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
ipInputRef.current = e;
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -574,14 +619,28 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
setAuthTab(value as 'password' | 'key');
|
||||
form.setValue('authType', value as 'password' | 'key');
|
||||
setAuthTab(value as 'password' | 'key' | 'credential');
|
||||
form.setValue('authType', value as 'password' | 'key' | 'credential');
|
||||
// Clear other auth fields when switching
|
||||
if (value === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
form.setValue('credentialId', null);
|
||||
} else if (value === 'key') {
|
||||
form.setValue('password', '');
|
||||
form.setValue('credentialId', null);
|
||||
} else if (value === 'credential') {
|
||||
form.setValue('password', '');
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
||||
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
||||
<TabsTrigger value="credential">{t('hosts.credential')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
@@ -696,6 +755,18 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credential">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal">
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, {useState, useEffect, useMemo} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
||||
import React, {useState, useEffect, useMemo, useRef} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
FileEdit,
|
||||
Search,
|
||||
Upload,
|
||||
Info
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Pencil
|
||||
} from "lucide-react";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
|
||||
@@ -55,9 +58,30 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [draggedHost, setDraggedHost] = useState<SSHHost | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
|
||||
// Listen for refresh events from other components
|
||||
const handleHostsRefresh = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
|
||||
window.addEventListener('hosts:refresh', handleHostsRefresh);
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||
window.addEventListener('folders:changed', handleHostsRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hosts:refresh', handleHostsRefresh);
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||
window.removeEventListener('folders:changed', handleHostsRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
@@ -123,6 +147,118 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||
if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...host, folder: '' };
|
||||
await updateSSHHost(host.id, updatedHost);
|
||||
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderRename = async (oldName: string) => {
|
||||
if (!editingFolderName.trim() || editingFolderName === oldName) {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
await renameFolder(oldName, editingFolderName.trim());
|
||||
toast.success(t('hosts.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToRenameFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startFolderEdit = (folderName: string) => {
|
||||
setEditingFolder(folderName);
|
||||
setEditingFolderName(folderName);
|
||||
};
|
||||
|
||||
const cancelFolderEdit = () => {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, host: SSHHost) => {
|
||||
setDraggedHost(host);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedHost(null);
|
||||
setDragOverFolder(null);
|
||||
dragCounter.current = 0;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent, folderName: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current++;
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragOverFolder(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetFolder: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragOverFolder(null);
|
||||
|
||||
if (!draggedHost) return;
|
||||
|
||||
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder;
|
||||
|
||||
if (draggedHost.folder === newFolder) {
|
||||
setDraggedHost(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...draggedHost, folder: newFolder };
|
||||
await updateSSHHost(draggedHost.id, updatedHost);
|
||||
toast.success(t('hosts.movedToFolder', {
|
||||
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
|
||||
folder: targetFolder
|
||||
}));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToMoveToFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDraggedHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -248,13 +384,141 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('hosts.noHostsMessage')}
|
||||
</p>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('hosts.hostsCount', { count: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative"
|
||||
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? t('hosts.importing') : t('hosts.importJson')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"
|
||||
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hosts.importJsonDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
tunnelConnections: [
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sample-ssh-hosts.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
{t('hosts.downloadSample')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open('https://docs.termix.site/json-import', '_blank');
|
||||
}}
|
||||
>
|
||||
{t('hosts.formatGuide')}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2"/>
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
{t('hosts.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonImport}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('hosts.noHostsMessage')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('hosts.getStartedMessage', { defaultValue: 'Use the Import JSON button above to add hosts from a JSON file.' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -398,14 +662,90 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, folder)}
|
||||
>
|
||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
{editingFolder === folder ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleFolderRename(folder);
|
||||
if (e.key === 'Escape') cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 text-sm px-2 flex-1"
|
||||
autoFocus
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderRename(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<Check className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t('hosts.uncategorized')) {
|
||||
startFolderEdit(folder);
|
||||
}
|
||||
}}
|
||||
title={folder !== t('hosts.uncategorized') ? 'Click to rename folder' : ''}
|
||||
>
|
||||
{folder}
|
||||
</span>
|
||||
{folder !== t('hosts.uncategorized') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startFolderEdit(folder);
|
||||
}}
|
||||
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title="Rename folder"
|
||||
>
|
||||
<Pencil className="h-3 w-3"/>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
@@ -416,7 +756,12 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, host)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${
|
||||
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -436,6 +781,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== '' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700"
|
||||
title={`Remove from folder "${host.folder}"`}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3"/>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Progress} from "@/components/ui/progress.tsx"
|
||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
|
||||
import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface ServerProps {
|
||||
@@ -279,9 +279,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(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/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -357,7 +361,7 @@ style.innerHTML = `
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -365,7 +369,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -373,7 +377,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {TunnelViewer} from "@/ui/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||
|
||||
interface TunnelConnection {
|
||||
228
src/ui/Desktop/DesktopApp.tsx
Normal file
228
src/ui/Desktop/DesktopApp.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, {useState, useEffect} from "react"
|
||||
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
|
||||
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx"
|
||||
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
|
||||
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
|
||||
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
|
||||
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { getUserInfo } 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;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
||||
const {currentTab, tabs} = useTabs();
|
||||
|
||||
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 handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextView)
|
||||
return next
|
||||
})
|
||||
setView(nextView)
|
||||
}
|
||||
|
||||
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||
setIsAuthenticated(true)
|
||||
setIsAdmin(authData.isAdmin)
|
||||
setUsername(authData.username)
|
||||
}
|
||||
|
||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||
const showHome = currentTabData?.type === 'home';
|
||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||
const showAdmin = currentTabData?.type === 'admin';
|
||||
const showProfile = currentTabData?.type === 'profile';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div>
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
rgba(255, 255, 255, 0.03) 49%,
|
||||
rgba(255, 255, 255, 0.03) 51%,
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: '80px 80px'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
>
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showTerminalView ? "visible" : "hidden",
|
||||
pointerEvents: showTerminalView ? "auto" : "none",
|
||||
height: showTerminalView ? "100vh" : 0,
|
||||
width: showTerminalView ? "100%" : 0,
|
||||
position: showTerminalView ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showHome ? "visible" : "hidden",
|
||||
pointerEvents: showHome ? "auto" : "none",
|
||||
height: showHome ? "100vh" : 0,
|
||||
width: showHome ? "100%" : 0,
|
||||
position: showHome ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showSshManager ? "visible" : "hidden",
|
||||
pointerEvents: showSshManager ? "auto" : "none",
|
||||
height: showSshManager ? "100vh" : 0,
|
||||
width: showSshManager ? "100%" : 0,
|
||||
position: showSshManager ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showAdmin ? "visible" : "hidden",
|
||||
pointerEvents: showAdmin ? "auto" : "none",
|
||||
height: showAdmin ? "100vh" : 0,
|
||||
width: showAdmin ? "100%" : 0,
|
||||
position: showAdmin ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showProfile ? "visible" : "hidden",
|
||||
pointerEvents: showProfile ? "auto" : "none",
|
||||
height: showProfile ? "100vh" : 0,
|
||||
width: showProfile ? "100%" : 0,
|
||||
position: showProfile ? "static" : "absolute",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopApp() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesktopApp
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
||||
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
||||
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageAlertManager} from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
@@ -1,11 +1,11 @@
|
||||
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 {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";
|
||||
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -17,13 +17,9 @@ import {
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin
|
||||
} from "../main-axios.ts";
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
verifyTOTPLogin,
|
||||
setCookie
|
||||
} from "../../main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Terminal} from "@/ui/Apps/Terminal/Terminal.tsx";
|
||||
import {Server as ServerView} from "@/ui/Apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/Apps/File Manager/FileManager.tsx";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
|
||||
import {Server as ServerView} from "@/ui/Desktop/Apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
@@ -2,7 +2,7 @@ import React, {useState} from "react";
|
||||
import {CardTitle} from "@/components/ui/card.tsx";
|
||||
import {ChevronDown, Folder} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
|
||||
import {Host} from "@/ui/Desktop/Navigation/Hosts/Host.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
@@ -3,7 +3,7 @@ import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose
|
||||
} from "@/components/ui/sheet";
|
||||
} from "@/components/ui/sheet.tsx";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
@@ -47,9 +47,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
@@ -388,6 +388,7 @@ export function LeftSidebar({
|
||||
<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>
|
||||
<DropdownMenuItem
|
||||
111
src/ui/Desktop/Navigation/Tabs/TabDropdown.tsx
Normal file
111
src/ui/Desktop/Navigation/Tabs/TabDropdown.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
ChevronDown,
|
||||
Home,
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
Shield as AdminIcon,
|
||||
Network as SshManagerIcon
|
||||
} from "lucide-react";
|
||||
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function TabDropdown(): React.ReactElement {
|
||||
const { tabs, currentTab, setCurrentTab } = useTabs();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTabIcon = (tabType: Tab['type']) => {
|
||||
switch (tabType) {
|
||||
case 'home':
|
||||
return <Home className="h-4 w-4" />;
|
||||
case 'terminal':
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
case 'server':
|
||||
return <ServerIcon className="h-4 w-4" />;
|
||||
case 'file_manager':
|
||||
return <FolderIcon className="h-4 w-4" />;
|
||||
case 'ssh_manager':
|
||||
return <SshManagerIcon className="h-4 w-4" />;
|
||||
case 'admin':
|
||||
return <AdminIcon className="h-4 w-4" />;
|
||||
default:
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabDisplayTitle = (tab: Tab) => {
|
||||
switch (tab.type) {
|
||||
case 'home':
|
||||
return t('nav.home');
|
||||
case 'server':
|
||||
return tab.title || t('nav.serverStats');
|
||||
case 'file_manager':
|
||||
return tab.title || t('nav.fileManager');
|
||||
case 'ssh_manager':
|
||||
return tab.title || t('nav.sshManager');
|
||||
case 'admin':
|
||||
return tab.title || t('nav.admin');
|
||||
case 'terminal':
|
||||
default:
|
||||
return tab.title || t('nav.terminal');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabSwitch = (tabId: number) => {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
// If only one tab (home), don't show dropdown
|
||||
if (tabs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-[#303032]"
|
||||
title={t('nav.tabNavigation', { defaultValue: 'Tab Navigation' })}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 bg-[#18181b] border-[#303032] text-white"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
onClick={() => handleTabSwitch(tab.id)}
|
||||
className={`flex items-center gap-2 cursor-pointer px-3 py-2 ${
|
||||
isActive
|
||||
? 'bg-[#1d1d1f] text-white'
|
||||
: 'hover:bg-[#2d2d30] text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{getTabIcon(tab.type)}
|
||||
<span className="flex-1 truncate">
|
||||
{getTabDisplayTitle(tab)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useState} from "react";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
|
||||
import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {Tab} from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -14,6 +14,7 @@ import {Input} from "@/components/ui/input.tsx";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {TabDropdown} from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
|
||||
|
||||
interface TopNavbarProps {
|
||||
isTopbarOpen: boolean;
|
||||
@@ -59,13 +60,9 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
setSelectedTabIds([]);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (selectedTabIds.length === 0) return;
|
||||
|
||||
const value = e.currentTarget.value;
|
||||
let commandToSend = '';
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -266,6 +263,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px]"
|
||||
@@ -19,7 +19,6 @@ interface PasswordResetProps {
|
||||
}
|
||||
|
||||
export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const {t} = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||
@@ -28,6 +27,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const {t} = useTranslation();
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
@@ -168,7 +168,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t('common.back')}
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -225,14 +225,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t('common.back')}
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -6,12 +6,12 @@ import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {User, Shield, Key, AlertCircle} from "lucide-react";
|
||||
import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
|
||||
import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
|
||||
import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {LanguageSwitcher} from "@/components/LanguageSwitcher";
|
||||
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
46
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
46
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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 (
|
||||
<div className="w-full h-[80px] bg-[#18181B] flex items-center p-2 gap-2">
|
||||
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||
<Menu/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
81
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, {useState} from "react";
|
||||
import {CardTitle} from "@/components/ui/card.tsx";
|
||||
import {ChevronDown, Folder} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx";
|
||||
|
||||
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 FolderCardProps {
|
||||
folderName: string;
|
||||
hosts: SSHHost[];
|
||||
onHostConnect: () => void;
|
||||
}
|
||||
|
||||
export function FolderCard({folderName, hosts, onHostConnect}: FolderCardProps): React.ReactElement {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||
style={{padding: '0', margin: '0'}}>
|
||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||
<div className="flex gap-2 pr-10">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Folder size={16} strokeWidth={3}/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col p-2 gap-y-3">
|
||||
{hosts.map((host, index) => (
|
||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||
<Host host={host} onHostConnect={onHostConnect}/>
|
||||
|
||||
{index < hosts.length - 1 && (
|
||||
<div className="relative -mx-2">
|
||||
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
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;
|
||||
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 HostProps {
|
||||
host: SSHHost;
|
||||
onHostConnect: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
|
||||
intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({type: 'terminal', title, hostConfig: host});
|
||||
onHostConnect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal/>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
227
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from "@/components/ui/sidebar.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
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 {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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
Termix
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
</SidebarGroupLabel>
|
||||
</SidebarHeader>
|
||||
<Separator/>
|
||||
<SidebarContent className="px-2 py-2">
|
||||
<div className="!bg-[#222225] rounded-lg mb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hostsError && (
|
||||
<div className="px-1">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{t('leftSidebar.failedToLoadHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t('hosts.loadingHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<FolderCard
|
||||
key={`folder-${folder}`}
|
||||
folderName={folder}
|
||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||
onHostConnect={onHostConnect}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</SidebarProvider>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
339
src/ui/Mobile/Apps/Terminal/Terminal.tsx
Normal file
339
src/ui/Mobile/Apps/Terminal/Terminal.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
|
||||
import {useXTerm} from 'react-xtermjs';
|
||||
import {FitAddon} from '@xterm/addon-fit';
|
||||
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible},
|
||||
ref
|
||||
) {
|
||||
const {t} = useTranslation();
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEBOUNCE_MS = 140;
|
||||
|
||||
useEffect(() => {
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === 'function') {
|
||||
(terminal as any).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = {cols, rows};
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
notifyTimerRef.current = setTimeout(() => {
|
||||
const next = pendingSizeRef.current;
|
||||
const last = lastSentSizeRef.current;
|
||||
if (!next) return;
|
||||
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
|
||||
lastSentSizeRef.current = next;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||
}
|
||||
},
|
||||
notifyResize: () => {
|
||||
try {
|
||||
const cols = terminal?.cols ?? undefined;
|
||||
const rows = terminal?.rows ?? undefined;
|
||||
if (typeof cols === 'number' && typeof rows === 'number') {
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}), [terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
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);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: false,
|
||||
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,
|
||||
disableStdin: true,
|
||||
cursorInactiveStyle: "bar",
|
||||
};
|
||||
|
||||
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 textarea = xtermRef.current.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.readOnly = true;
|
||||
textarea.blur();
|
||||
}
|
||||
|
||||
terminal.focus = () => {};
|
||||
|
||||
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);
|
||||
|
||||
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
}, 0);
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
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/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return () => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
}
|
||||
}, [isVisible, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
}, [isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
183
src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx
Normal file
183
src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, {useState, useCallback, useEffect} from "react";
|
||||
import Keyboard from "react-simple-keyboard";
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
import "./kb-dark-theme.css";
|
||||
|
||||
interface TerminalKeyboardProps {
|
||||
onSendInput: (input: string) => void;
|
||||
onLayoutChange: () => void;
|
||||
}
|
||||
|
||||
export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboardProps) {
|
||||
const [layoutName, setLayoutName] = useState("default");
|
||||
const [isCtrl, setIsCtrl] = useState(false);
|
||||
const [isAlt, setIsAlt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (onLayoutChange) {
|
||||
const timeoutId = setTimeout(() => onLayoutChange(), 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [layoutName, onLayoutChange]);
|
||||
|
||||
const onKeyPress = useCallback((button: string) => {
|
||||
if (button === "{shift}") {
|
||||
setLayoutName("shift");
|
||||
return;
|
||||
}
|
||||
if (button === "{unshift}") {
|
||||
setLayoutName("default");
|
||||
return;
|
||||
}
|
||||
if (button === "{more}") {
|
||||
setLayoutName("more");
|
||||
return;
|
||||
}
|
||||
if (button === "{less}") {
|
||||
setLayoutName("default");
|
||||
return;
|
||||
}
|
||||
if (button === "{hide}") {
|
||||
setLayoutName("hide");
|
||||
return;
|
||||
}
|
||||
if (button === "{unhide}") {
|
||||
setLayoutName("default");
|
||||
return;
|
||||
}
|
||||
|
||||
if (button === "{ctrl}") {
|
||||
setIsCtrl(prev => !prev);
|
||||
return;
|
||||
}
|
||||
if (button === "{alt}") {
|
||||
setIsAlt(prev => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
let input = button;
|
||||
|
||||
const specialKeyMap: { [key: string]: string } = {
|
||||
"{esc}": "\x1b", "{enter}": "\r", "{tab}": "\t", "{backspace}": "\x7f",
|
||||
"{arrowUp}": "\x1b[A", "{arrowDown}": "\x1b[B", "{arrowRight}": "\x1b[C", "{arrowLeft}": "\x1b[D",
|
||||
"{home}": "\x1b[H", "{end}": "\x1b[F", "{pgUp}": "\x1b[5~", "{pgDn}": "\x1b[6~",
|
||||
"F1": "\x1bOP", "F2": "\x1bOQ", "F3": "\x1bOR", "F4": "\x1bOS",
|
||||
"F5": "\x1b[15~", "F6": "\x1b[17~", "F7": "\x1b[18~", "F8": "\x1b[19~",
|
||||
"F9": "\x1b[20~", "F10": "\x1b[21~", "F11": "\x1b[23~", "F12": "\x1b[24~",
|
||||
"{space}": " "
|
||||
};
|
||||
|
||||
if (specialKeyMap[input]) {
|
||||
input = specialKeyMap[input];
|
||||
}
|
||||
|
||||
if (isCtrl) {
|
||||
if (input.length === 1) {
|
||||
const charCode = input.toUpperCase().charCodeAt(0);
|
||||
if (charCode >= 64 && charCode <= 95) {
|
||||
input = String.fromCharCode(charCode - 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAlt) {
|
||||
input = `\x1b${input}`;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(20);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Vibration failed:", e);
|
||||
}
|
||||
|
||||
onSendInput(input);
|
||||
}, [onSendInput, isCtrl, isAlt]);
|
||||
|
||||
const buttonTheme = [
|
||||
{
|
||||
class: "hg-space-big",
|
||||
buttons: "{space}",
|
||||
},
|
||||
{
|
||||
class: "hg-space-medium",
|
||||
buttons: "{enter} {backspace}",
|
||||
},
|
||||
{
|
||||
class: "hg-space-small",
|
||||
buttons: "{hide} {unhide} {less} {more}",
|
||||
}
|
||||
];
|
||||
|
||||
if (isCtrl) {
|
||||
buttonTheme.push({class: "key-active", buttons: "{ctrl}"});
|
||||
}
|
||||
if (isAlt) {
|
||||
buttonTheme.push({class: "key-active", buttons: "{alt}"});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="z-10">
|
||||
<Keyboard
|
||||
layout={{
|
||||
default: [
|
||||
"{esc} {tab} {ctrl} {alt} {arrowLeft} {arrowRight} {arrowUp} {arrowDown}",
|
||||
"q w e r t y u i o p",
|
||||
"a s d f g h j k l",
|
||||
"{shift} z x c v b n m {backspace}",
|
||||
"{hide} {more} {space} {enter}",
|
||||
],
|
||||
shift: [
|
||||
"{esc} {tab} {ctrl} {alt} {arrowLeft} {arrowRight} {arrowUp} {arrowDown}",
|
||||
"Q W E R T Y U I O P",
|
||||
"A S D F G H J K L",
|
||||
"{unshift} Z X C V B N M {backspace}",
|
||||
"{hide} {more} {space} {enter}",
|
||||
],
|
||||
more: [
|
||||
"{esc} {tab} {ctrl} {alt} {end} {home} {pgUp} {pgDn}",
|
||||
"1 2 3 4 5 6 7 8 9 0",
|
||||
"! @ # $ % ^ & * ( ) _ +",
|
||||
"[ ] { } | \\ ; : ' \" , . / < >",
|
||||
"F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"{arrowLeft} {arrowRight} {arrowUp} {arrowDown} {backspace}",
|
||||
"{hide} {less} {space} {enter}",
|
||||
],
|
||||
hide: [
|
||||
"{unhide}"
|
||||
]
|
||||
}}
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyPress}
|
||||
display={{
|
||||
"{shift}": "up",
|
||||
"{unshift}": "dn",
|
||||
"{backspace}": "back",
|
||||
"{more}": "more",
|
||||
"{less}": "less",
|
||||
"{space}": "space",
|
||||
"{enter}": "enter",
|
||||
"{arrowLeft}": "←",
|
||||
"{arrowRight}": "→",
|
||||
"{arrowUp}": "↑",
|
||||
"{arrowDown}": "↓",
|
||||
"{hide}": "hide",
|
||||
"{unhide}": "unhide",
|
||||
"{esc}": "esc",
|
||||
"{tab}": "tab",
|
||||
"{ctrl}": "ctrl",
|
||||
"{alt}": "alt",
|
||||
"{end}": "end",
|
||||
"{home}": "home",
|
||||
"{pgUp}": "pgUp",
|
||||
"{pgDn}": "pgDn",
|
||||
}}
|
||||
theme={"hg-theme-default dark-theme"}
|
||||
useTouchEvents={true}
|
||||
disableButtonHold={true}
|
||||
buttonTheme={buttonTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/ui/Mobile/Apps/Terminal/kb-dark-theme.css
Normal file
40
src/ui/Mobile/Apps/Terminal/kb-dark-theme.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.simple-keyboard.dark-theme {
|
||||
background-color: rgb(24, 24, 27);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.simple-keyboard.dark-theme .hg-button {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #bfbfbf;
|
||||
border-bottom-color: rgb(122, 122, 122);
|
||||
}
|
||||
|
||||
.simple-keyboard.dark-theme .hg-button:active {
|
||||
background: rgba(83, 83, 83, 0.5);
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
#root .simple-keyboard.dark-theme + .simple-keyboard-preview {
|
||||
background: rgba(83, 83, 83, 0.5);
|
||||
}
|
||||
|
||||
.dark-theme .hg-button.key-active {
|
||||
background: rgba(126, 126, 126, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hg-space-big {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.hg-space-medium {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.hg-space-small {
|
||||
width: 1px;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
206
src/ui/Mobile/MobileApp.tsx
Normal file
206
src/ui/Mobile/MobileApp.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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/Navigation/BottomNavbar.tsx";
|
||||
import {LeftSidebar} from "@/ui/Mobile/Navigation/LeftSidebar.tsx";
|
||||
import {TabProvider, useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
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 {t} = useTranslation();
|
||||
const {tabs, currentTab, getTab} = useTabs();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||
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)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fitCurrentTerminal()
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
const handleKeyboardLayoutChange = () => {
|
||||
fitCurrentTerminal();
|
||||
}
|
||||
|
||||
function handleKeyboardInput(input: string) {
|
||||
const currentTerminalTab = getTab(currentTab as number);
|
||||
if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) {
|
||||
currentTerminalTab.terminalRef.current.sendInput(input);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-[#09090b]">
|
||||
<p className="text-white">{t('common.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 (
|
||||
<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 relative">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="absolute inset-0 mb-2"
|
||||
style={{
|
||||
visibility: tab.id === currentTab ? 'visible' : 'hidden',
|
||||
opacity: ready ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
ref={tab.terminalRef}
|
||||
hostConfig={tab.hostConfig}
|
||||
isVisible={tab.id === currentTab}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{tabs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-white gap-3 px-4 text-center">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t('mobile.selectHostToStart')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-300 max-w-xs">
|
||||
{t('mobile.limitedSupportMessage')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentTab &&
|
||||
<div className="mb-1 z-10">
|
||||
<TerminalKeyboard onSendInput={handleKeyboardInput} onLayoutChange={handleKeyboardLayoutChange}/>
|
||||
</div>
|
||||
}
|
||||
<BottomNavbar
|
||||
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm z-10"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}} className="pointer-events-auto">
|
||||
<LeftSidebar
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
setIsSidebarOpen={setIsSidebarOpen}
|
||||
onHostConnect={closeSidebar}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MobileApp: FC = () => {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent/>
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Menu, X, Terminal as TerminalIcon} from "lucide-react";
|
||||
import {useTabs} from "@/ui/Mobile/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 (
|
||||
<div className="w-full h-[50px] bg-[#18181B] items-center p-1">
|
||||
<div className="flex gap-2 !mb-0.5">
|
||||
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||
<Menu/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, {useState} from "react";
|
||||
import {CardTitle} from "@/components/ui/card.tsx";
|
||||
import {ChevronDown, Folder} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Host} from "@/ui/Mobile/Navigation/Hosts/Host.tsx";
|
||||
|
||||
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 FolderCardProps {
|
||||
folderName: string;
|
||||
hosts: SSHHost[];
|
||||
onHostConnect: () => void;
|
||||
}
|
||||
|
||||
export function FolderCard({folderName, hosts, onHostConnect}: FolderCardProps): React.ReactElement {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||
style={{padding: '0', margin: '0'}}>
|
||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||
<div className="flex gap-2 pr-10">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Folder size={16} strokeWidth={3}/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col p-2 gap-y-3">
|
||||
{hosts.map((host, index) => (
|
||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||
<Host host={host} onHostConnect={onHostConnect}/>
|
||||
|
||||
{index < hosts.length - 1 && (
|
||||
<div className="relative -mx-2">
|
||||
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/ui/Mobile/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
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/Navigation/Tabs/TabContext.tsx";
|
||||
|
||||
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 HostProps {
|
||||
host: SSHHost;
|
||||
onHostConnect: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
|
||||
intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({type: 'terminal', title, hostConfig: host});
|
||||
onHostConnect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal/>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal file
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from "@/components/ui/sidebar.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
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/Navigation/Hosts/FolderCard.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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
Termix
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
</SidebarGroupLabel>
|
||||
</SidebarHeader>
|
||||
<Separator/>
|
||||
<SidebarContent className="px-2 py-2">
|
||||
<div className="!bg-[#222225] rounded-lg">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hostsError && (
|
||||
<div className="px-1">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{t('leftSidebar.failedToLoadHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t('hosts.loadingHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<FolderCard
|
||||
key={`folder-${folder}`}
|
||||
folderName={folder}
|
||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||
onHostConnect={onHostConnect}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
src/ui/Mobile/Navigation/Tabs/TabContext.tsx
Normal file
100
src/ui/Mobile/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>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,12 @@ interface SSHHostData {
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: 'password' | 'key';
|
||||
authType: 'password' | 'key' | 'credential';
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
@@ -38,6 +39,7 @@ interface SSHHost {
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
@@ -158,15 +160,28 @@ interface OIDCAuthorize {
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function setCookie(name: string, value: string, days = 7): void {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
export function setCookie(name: string, value: string, days = 7): void {
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
if (isElectron) {
|
||||
localStorage.setItem(name, value);
|
||||
} else {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
if (isElectron) {
|
||||
const token = localStorage.getItem(name) || undefined;
|
||||
return token;
|
||||
} else {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
const token = parts.length === 2 ? parts.pop()?.split(';').shift() : undefined;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
function createApiInstance(baseURL: string): AxiosInstance {
|
||||
@@ -180,6 +195,8 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('No token found, Authorization header not set');
|
||||
}
|
||||
return config;
|
||||
});
|
||||
@@ -201,34 +218,64 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
||||
// API INSTANCES
|
||||
// ============================================================================
|
||||
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
let apiHost = import.meta.env.VITE_API_HOST || 'localhost';
|
||||
let apiPort = 8081;
|
||||
|
||||
if (isElectron) {
|
||||
apiPort = 8081;
|
||||
}
|
||||
|
||||
function getApiUrl(path: string, defaultPort: number): string {
|
||||
if (isElectron) {
|
||||
return `http://127.0.0.1:${defaultPort}${path}`;
|
||||
} else if (isDev) {
|
||||
return `http://${apiHost}:${defaultPort}${path}`;
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-port backend architecture (original design)
|
||||
// SSH Host Management API (port 8081)
|
||||
export const sshHostApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8081/ssh' : '/ssh'
|
||||
export let sshHostApi = createApiInstance(
|
||||
getApiUrl('/ssh', 8081)
|
||||
);
|
||||
|
||||
// Tunnel Management API (port 8083)
|
||||
export const tunnelApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8083/ssh' : '/ssh'
|
||||
export let tunnelApi = createApiInstance(
|
||||
getApiUrl('/ssh', 8083)
|
||||
);
|
||||
|
||||
// File Manager Operations API (port 8084) - SSH file operations
|
||||
export const fileManagerApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8084/ssh/file_manager' : '/ssh/file_manager'
|
||||
export let fileManagerApi = createApiInstance(
|
||||
getApiUrl('/ssh/file_manager', 8084)
|
||||
);
|
||||
|
||||
// Server Statistics API (port 8085)
|
||||
export const statsApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8085' : ''
|
||||
export let statsApi = createApiInstance(
|
||||
getApiUrl('', 8085)
|
||||
);
|
||||
|
||||
// Authentication API (port 8081) - includes users, alerts, version, releases
|
||||
export const authApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8081' : ''
|
||||
export let authApi = createApiInstance(
|
||||
getApiUrl('', 8081)
|
||||
);
|
||||
|
||||
// Function to update API instances with new port (for Electron)
|
||||
function updateApiPorts(port: number) {
|
||||
apiPort = port;
|
||||
sshHostApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||
tunnelApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||
fileManagerApi = createApiInstance(`http://127.0.0.1:${port}/ssh/file_manager`);
|
||||
statsApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||
authApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ERROR HANDLING
|
||||
// ============================================================================
|
||||
@@ -293,10 +340,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
tags: hostData.tags || [],
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType,
|
||||
authType: hostData.authType,
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
@@ -344,10 +393,12 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
tags: hostData.tags || [],
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType,
|
||||
authType: hostData.authType,
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
@@ -772,8 +823,9 @@ export async function getOIDCConfig(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/users/oidc-config');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch OIDC config');
|
||||
} catch (error: any) {
|
||||
console.warn('Failed to fetch OIDC config:', error.response?.data?.error || error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,7 +997,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string)
|
||||
|
||||
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||
try {
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -956,7 +1008,7 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }>
|
||||
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since alerts endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -970,9 +1022,7 @@ export async function dismissAlert(userId: string, alertId: string): Promise<any
|
||||
|
||||
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since releases endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch releases RSS');
|
||||
@@ -981,9 +1031,7 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
|
||||
export async function getVersionInfo(): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since version endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const response = await apiInstance.get('/version/');
|
||||
const response = await authApi.get('/version');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch version info');
|
||||
@@ -1001,4 +1049,105 @@ export async function getDatabaseHealth(): Promise<any> {
|
||||
} catch (error) {
|
||||
handleApiError(error, 'check database health');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH CREDENTIALS MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getCredentials(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/credentials');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credentials');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialDetails(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential details');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCredential(credentialData: any): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/credentials', credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'create credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCredential(credentialId: number, credentialData: any): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.put(`/credentials/${credentialId}`, credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'update credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCredential(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'delete credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialHosts(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}/hosts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential hosts');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialFolders(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/credentials/folders');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential folders');
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCredentialToHost(credentialId: number, hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post(`/credentials/${credentialId}/apply-to-host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'apply credential to host');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH FOLDER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getFoldersWithStats(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/ssh/db/folders/with-stats');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch folders with statistics');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameFolder(oldName: string, newName: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.put('/ssh/db/folders/rename', {
|
||||
oldName,
|
||||
newName
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'rename folder');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user